feat: initial gosleep-timer implementation

Go TUI sleep timer with modular architecture.
- Config: YAML-based, profile inheritance, auto WM detection
- Engine: timer, executor, stage pipeline (pre→timer→post)
- Modules: workspace (niri/hyprland/kde), media, lock, kill,
  notify, sound, brightness, mute, custom, script
- TUI: bubbletea app with profiles, module toggles, progress bar
- CLI: run, list-profiles, export, import, history, stats
- History: JSONL log with statistics
- QR: config export as ASCII QR
This commit is contained in:
2026-07-03 01:43:22 +02:00
commit 8501fd074c
34 changed files with 3401 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
package brightness
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type BrightnessModule struct {
tool string
}
func (m *BrightnessModule) Name() string {
return "brightness"
}
func (m *BrightnessModule) Enabled(cfg *config.Modules) bool {
return cfg.Brightness != nil && cfg.Brightness.Enabled
}
func (m *BrightnessModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
val := ctx.Config.Brightness.Value
if val < 0 {
val = 0
}
if val > 100 {
val = 100
}
switch stage {
case modules.StagePre:
if m.tool == "light" {
return []string{fmt.Sprintf("light -S %d", val)}
}
return []string{fmt.Sprintf("brightnessctl set %d%%", val)}
case modules.StagePost:
if m.tool == "light" {
return []string{"light -S 100"}
}
return []string{"brightnessctl set 100%"}
}
return nil
}
func (m *BrightnessModule) Validate() error {
if _, err := exec.LookPath("brightnessctl"); err == nil {
m.tool = "brightnessctl"
return nil
}
if _, err := exec.LookPath("light"); err == nil {
m.tool = "light"
return nil
}
return fmt.Errorf("brightness: neither brightnessctl nor light found in PATH")
}
func init() {
modules.Register(&BrightnessModule{})
}
+34
View File
@@ -0,0 +1,34 @@
package custom
import (
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type CustomModule struct{}
func (m *CustomModule) Name() string {
return "custom"
}
func (m *CustomModule) Enabled(cfg *config.Modules) bool {
return cfg.Custom != nil && cfg.Custom.Enabled
}
func (m *CustomModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
switch stage {
case modules.StagePre:
return ctx.Config.Custom.Pre
case modules.StagePost:
return ctx.Config.Custom.Post
}
return nil
}
func (m *CustomModule) Validate() error {
return nil
}
func init() {
modules.Register(&CustomModule{})
}
+43
View File
@@ -0,0 +1,43 @@
package kill
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type KillModule struct{}
func (m *KillModule) Name() string {
return "kill"
}
func (m *KillModule) Enabled(cfg *config.Modules) bool {
return cfg.Kill != nil && cfg.Kill.Enabled && len(cfg.Kill.Processes) > 0
}
func (m *KillModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePre {
return nil
}
var cmds []string
for _, proc := range ctx.Config.Kill.Processes {
if proc == "" {
continue
}
cmds = append(cmds, fmt.Sprintf("pkill %s", proc))
}
return cmds
}
func (m *KillModule) Validate() error {
_, err := exec.LookPath("pkill")
return err
}
func init() {
modules.Register(&KillModule{})
}
+36
View File
@@ -0,0 +1,36 @@
package lock
import (
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type LockModule struct{}
func (m *LockModule) Name() string {
return "lock"
}
func (m *LockModule) Enabled(cfg *config.Modules) bool {
return cfg.Lock != nil && cfg.Lock.Enabled
}
func (m *LockModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePost {
return nil
}
cmd := ctx.Config.Lock.Command
if cmd == "" {
cmd = "loginctl lock-session"
}
return []string{cmd}
}
func (m *LockModule) Validate() error {
// No strict validation — custom commands may be set
return nil
}
func init() {
modules.Register(&LockModule{})
}
+40
View File
@@ -0,0 +1,40 @@
package media
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type MediaModule struct{}
func (m *MediaModule) Name() string {
return "media"
}
func (m *MediaModule) Enabled(cfg *config.Modules) bool {
return cfg.Media != nil && cfg.Media.Enabled
}
func (m *MediaModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePre {
return nil
}
if ctx.Config.Media.Action == "none" {
return nil
}
return []string{fmt.Sprintf("playerctl %s", ctx.Config.Media.Action)}
}
func (m *MediaModule) Validate() error {
if _, err := exec.LookPath("playerctl"); err != nil {
return fmt.Errorf("media: playerctl not found")
}
return nil
}
func init() {
modules.Register(&MediaModule{})
}
+39
View File
@@ -0,0 +1,39 @@
package modules
import (
"context"
"github.com/forust/gosleep-timer/config"
)
type Stage string
const (
StagePre Stage = "pre"
StagePost Stage = "post"
)
type ModuleContext struct {
Context context.Context
Profile string
Duration string
Config *config.Modules
}
type ModuleResult struct {
Module string
Cmd string
Output string
Error error
Stage Stage
}
type Module interface {
Name() string
// Enabled returns true if this module should run for the given config
Enabled(cfg *config.Modules) bool
// BuildCmds returns shell commands for the given stage (pre/post)
BuildCmds(ctx *ModuleContext, stage Stage) []string
// Validate checks if required binaries exist
Validate() error
}
+53
View File
@@ -0,0 +1,53 @@
package mute
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type MuteModule struct {
tool string
}
func (m *MuteModule) Name() string {
return "mute"
}
func (m *MuteModule) Enabled(cfg *config.Modules) bool {
return cfg.Mute != nil && cfg.Mute.Enabled
}
func (m *MuteModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
switch stage {
case modules.StagePre:
if m.tool == "wpctl" {
return []string{"wpctl set-mute @DEFAULT_AUDIO_SINK@ 1"}
}
return []string{"pactl set-sink-mute @DEFAULT_SINK@ 1"}
case modules.StagePost:
if m.tool == "wpctl" {
return []string{"wpctl set-mute @DEFAULT_AUDIO_SINK@ 0"}
}
return []string{"pactl set-sink-mute @DEFAULT_SINK@ 0"}
}
return nil
}
func (m *MuteModule) Validate() error {
if _, err := exec.LookPath("pactl"); err == nil {
m.tool = "pactl"
return nil
}
if _, err := exec.LookPath("wpctl"); err == nil {
m.tool = "wpctl"
return nil
}
return fmt.Errorf("mute: neither pactl nor wpctl found in PATH")
}
func init() {
modules.Register(&MuteModule{})
}
+48
View File
@@ -0,0 +1,48 @@
package notify
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type NotifyModule struct{}
func (m *NotifyModule) Name() string {
return "notify"
}
func (m *NotifyModule) Enabled(cfg *config.Modules) bool {
return cfg.Notify != nil && cfg.Notify.Enabled
}
func (m *NotifyModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePost {
return nil
}
title := "gosleep-timer"
body := fmt.Sprintf("Timer %s finished", ctx.Duration)
cmds := []string{fmt.Sprintf("notify-send %q %q", title, body)}
if ctx.Config.Notify.Sound != "" {
if _, err := exec.LookPath("paplay"); err == nil {
cmds = append(cmds, fmt.Sprintf("paplay %s", ctx.Config.Notify.Sound))
} else if _, err := exec.LookPath("aplay"); err == nil {
cmds = append(cmds, fmt.Sprintf("aplay %s", ctx.Config.Notify.Sound))
}
}
return cmds
}
func (m *NotifyModule) Validate() error {
_, err := exec.LookPath("notify-send")
return err
}
func init() {
modules.Register(&NotifyModule{})
}
+53
View File
@@ -0,0 +1,53 @@
package modules
import "github.com/forust/gosleep-timer/config"
var registry []Module
func Register(m Module) {
registry = append(registry, m)
}
func GetAll() []Module {
return registry
}
func GetEnabled(cfg *config.Modules) []Module {
var enabled []Module
for _, m := range registry {
if m.Enabled(cfg) {
enabled = append(enabled, m)
}
}
return enabled
}
func CollectPreCmds(ctx *ModuleContext) []string {
var cmds []string
for _, m := range GetEnabled(ctx.Config) {
cmds = append(cmds, m.BuildCmds(ctx, StagePre)...)
}
return cmds
}
func CollectPostCmds(ctx *ModuleContext) []string {
var cmds []string
for _, m := range GetEnabled(ctx.Config) {
cmds = append(cmds, m.BuildCmds(ctx, StagePost)...)
}
return cmds
}
func ValidateAll() []error {
var errs []error
for _, m := range registry {
if err := m.Validate(); err != nil {
errs = append(errs, err)
}
}
return errs
}
func Init() {
// registry starts empty; modules register themselves via init() or Register()
}
+40
View File
@@ -0,0 +1,40 @@
package script
import (
"fmt"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type ScriptModule struct{}
func (m *ScriptModule) Name() string {
return "script"
}
func (m *ScriptModule) Enabled(cfg *config.Modules) bool {
return cfg.Script != nil && cfg.Script.Enabled && cfg.Script.Path != ""
}
func (m *ScriptModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
scriptPath := ctx.Config.Script.Path
switch stage {
case modules.StagePre:
return []string{fmt.Sprintf("%s pre %s %s", scriptPath, ctx.Profile, ctx.Duration)}
case modules.StagePost:
return []string{fmt.Sprintf("%s post %s %s", scriptPath, ctx.Profile, ctx.Duration)}
}
return nil
}
func (m *ScriptModule) Validate() error {
// Validation happens after config is loaded, so we check at registration time
// that the module itself is valid, but path is per-profile.
// Return nil here; path validation happens at runtime in BuildCmds.
return nil
}
func init() {
modules.Register(&ScriptModule{})
}
+74
View File
@@ -0,0 +1,74 @@
package sound
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type SoundModule struct {
player string
}
func (m *SoundModule) Name() string {
return "sound"
}
func (m *SoundModule) Enabled(cfg *config.Modules) bool {
return cfg.Sound != nil && cfg.Sound.Enabled && cfg.Sound.File != ""
}
func (m *SoundModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePost {
return nil
}
file := ctx.Config.Sound.File
if file == "" {
return nil
}
player := m.player
if player == "" {
player = detectPlayer()
}
switch player {
case "paplay":
return []string{fmt.Sprintf("paplay %s", file)}
case "aplay":
return []string{fmt.Sprintf("aplay %s", file)}
case "ffplay":
return []string{fmt.Sprintf("ffplay -nodisp -autoexit %s", file)}
default:
return nil
}
}
func (m *SoundModule) Validate() error {
player := detectPlayer()
if player == "" {
return fmt.Errorf("no audio player found (paplay, aplay, or ffplay)")
}
m.player = player
return nil
}
func detectPlayer() string {
if _, err := exec.LookPath("paplay"); err == nil {
return "paplay"
}
if _, err := exec.LookPath("aplay"); err == nil {
return "aplay"
}
if _, err := exec.LookPath("ffplay"); err == nil {
return "ffplay"
}
return ""
}
func init() {
modules.Register(&SoundModule{})
}
+56
View File
@@ -0,0 +1,56 @@
package workspace
import (
"fmt"
"os/exec"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/modules"
)
type WorkspaceModule struct{}
func (m *WorkspaceModule) Name() string {
return "workspace"
}
func (m *WorkspaceModule) Enabled(cfg *config.Modules) bool {
return cfg.Workspace != nil && cfg.Workspace.Enabled
}
func (m *WorkspaceModule) BuildCmds(ctx *modules.ModuleContext, stage modules.Stage) []string {
if stage != modules.StagePre {
return nil
}
backend := ctx.Config.Workspace.Backend
if backend == "" || backend == "auto" {
backend = config.DetectDesktop()
}
n := ctx.Config.Workspace.Workspace
switch backend {
case "niri":
return []string{fmt.Sprintf("niri msg action focus-workspace %d", n)}
case "hyprland":
return []string{fmt.Sprintf("hyprctl dispatch workspace %d", n)}
case "kde":
return []string{fmt.Sprintf("qdbus org.kde.KWin /KWin setCurrentDesktop %d", n)}
default:
return nil
}
}
func (m *WorkspaceModule) Validate() error {
// Check common workspace binaries
for _, bin := range []string{"niri", "hyprctl", "qdbus"} {
if _, err := exec.LookPath(bin); err == nil {
return nil
}
}
return fmt.Errorf("workspace: no supported backend binary found (niri, hyprctl, qdbus)")
}
func init() {
modules.Register(&WorkspaceModule{})
}