chore: release v1.3.3
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+1
-1
@@ -537,7 +537,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gosleep-timer"
|
||||
version = "1.3.2"
|
||||
version = "1.3.3"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "gosleep-timer"
|
||||
version = "1.3.2"
|
||||
version = "1.3.3"
|
||||
authors = ["forust"]
|
||||
edition = "2024"
|
||||
rust-version = "1.88"
|
||||
|
||||
@@ -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 <number>
|
||||
hyprctl dispatch workspace <number>
|
||||
swaymsg workspace number <number>
|
||||
i3-msg workspace number <number>
|
||||
qdbus org.kde.KWin /KWin org.kde.KWin.setCurrentDesktop <number>
|
||||
```
|
||||
|
||||
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`
|
||||
|
||||
|
||||
+289
-84
@@ -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<ActionCommand> {
|
||||
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<ActionCommand> {
|
||||
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,13 +891,7 @@ fn monitor_command(action: &MonitorAction) -> ActionCommand {
|
||||
},
|
||||
"kde" => ActionCommand {
|
||||
label: "monitor",
|
||||
args: vec![
|
||||
"qdbus",
|
||||
"org.kde.ScreenSaver",
|
||||
"/ScreenSaver",
|
||||
"org.freedesktop.ScreenSaver.SetActive",
|
||||
"true",
|
||||
]
|
||||
args: vec!["kscreen-doctor", "--dpms", "off"]
|
||||
.into_iter()
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
@@ -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,11 +1370,12 @@ impl App {
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(area);
|
||||
frame.render_widget(
|
||||
Paragraph::new("gosleep-timer").style(
|
||||
Paragraph::new("gosleep-timer")
|
||||
.alignment(Alignment::Center)
|
||||
.style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
@@ -1380,29 +1383,28 @@ impl App {
|
||||
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<FieldKey> {
|
||||
visible_fields(config)
|
||||
.into_iter()
|
||||
|
||||
Reference in New Issue
Block a user