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:
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
@@ -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{})
|
||||
}
|
||||
Reference in New Issue
Block a user