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
+281
View File
@@ -0,0 +1,281 @@
package config
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
func DefaultConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("home dir: %w", err)
}
return filepath.Join(home, ".config", "gosleep-timer", "config.yaml"), nil
}
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
if cfg.Profiles == nil {
cfg.Profiles = make(map[string]Profile)
}
resolved := make(map[string]Profile, len(cfg.Profiles))
for name := range cfg.Profiles {
seen := make(map[string]bool)
r := resolveExtends(cfg.Profiles, name, seen)
if r == nil {
return nil, fmt.Errorf("circular extends detected for profile %q", name)
}
resolved[name] = *r
}
cfg.Profiles = resolved
return &cfg, nil
}
func Save(path string, cfg *Config) error {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("mkdir config dir: %w", err)
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
return nil
}
func LoadOrCreate(path string) (*Config, error) {
cfg, err := Load(path)
if err == nil {
return cfg, nil
}
if !os.IsNotExist(err) {
return nil, err
}
cfg = DefaultConfig()
if err := Save(path, cfg); err != nil {
return nil, fmt.Errorf("create default config: %w", err)
}
return cfg, nil
}
func SaveToString(cfg *Config) (string, error) {
data, err := yaml.Marshal(cfg)
if err != nil {
return "", fmt.Errorf("marshal config: %w", err)
}
return string(data), nil
}
func DetectDesktop() string {
if de := os.Getenv("XDG_CURRENT_DESKTOP"); de != "" {
lower := strings.ToLower(de)
if strings.Contains(lower, "hyprland") {
return "hyprland"
}
if strings.Contains(lower, "kde") || strings.Contains(lower, "plasma") {
return "kde"
}
if strings.Contains(lower, "niri") {
return "niri"
}
}
if err := exec.Command("hyprctl", "instances").Run(); err == nil {
return "hyprland"
}
if err := exec.Command("niri", "msg", "-h").Run(); err == nil {
return "niri"
}
return "generic"
}
func resolveExtends(profiles map[string]Profile, name string, seen map[string]bool) *Profile {
if seen[name] {
return nil
}
seen[name] = true
p, ok := profiles[name]
if !ok {
return nil
}
base := &Profile{}
if p.Extends != "" {
base = resolveExtends(profiles, p.Extends, seen)
if base == nil {
return nil
}
}
merged := &Profile{
Extends: p.Extends,
Modules: copyModules(base.Modules),
}
if p.Modules != nil {
if merged.Modules == nil {
merged.Modules = &Modules{}
}
overlayModules(merged.Modules, p.Modules)
}
return merged
}
func copyModules(m *Modules) *Modules {
if m == nil {
return nil
}
c := &Modules{}
if m.Workspace != nil {
v := *m.Workspace
c.Workspace = &v
}
if m.Media != nil {
v := *m.Media
c.Media = &v
}
if m.Lock != nil {
v := *m.Lock
c.Lock = &v
}
if m.Kill != nil {
v := *m.Kill
v.Processes = append([]string(nil), m.Kill.Processes...)
c.Kill = &v
}
if m.Notify != nil {
v := *m.Notify
c.Notify = &v
}
if m.Sound != nil {
v := *m.Sound
c.Sound = &v
}
if m.Brightness != nil {
v := *m.Brightness
c.Brightness = &v
}
if m.Mute != nil {
v := *m.Mute
c.Mute = &v
}
if m.Custom != nil {
v := *m.Custom
v.Pre = append([]string(nil), m.Custom.Pre...)
v.Post = append([]string(nil), m.Custom.Post...)
c.Custom = &v
}
if m.Script != nil {
v := *m.Script
c.Script = &v
}
return c
}
func overlayModules(base, overlay *Modules) {
if overlay.Workspace != nil {
if base.Workspace == nil {
base.Workspace = &WorkspaceConfig{}
}
base.Workspace.Enabled = overlay.Workspace.Enabled
if overlay.Workspace.Backend != "" {
base.Workspace.Backend = overlay.Workspace.Backend
}
if overlay.Workspace.Workspace != 0 {
base.Workspace.Workspace = overlay.Workspace.Workspace
}
}
if overlay.Media != nil {
if base.Media == nil {
base.Media = &MediaConfig{}
}
base.Media.Enabled = overlay.Media.Enabled
if overlay.Media.Action != "" {
base.Media.Action = overlay.Media.Action
}
}
if overlay.Lock != nil {
if base.Lock == nil {
base.Lock = &LockConfig{}
}
base.Lock.Enabled = overlay.Lock.Enabled
if overlay.Lock.Command != "" {
base.Lock.Command = overlay.Lock.Command
}
}
if overlay.Kill != nil {
base.Kill = &KillConfig{
Enabled: overlay.Kill.Enabled,
Processes: append([]string(nil), overlay.Kill.Processes...),
}
}
if overlay.Notify != nil {
if base.Notify == nil {
base.Notify = &NotifyConfig{}
}
base.Notify.Enabled = overlay.Notify.Enabled
if overlay.Notify.Sound != "" {
base.Notify.Sound = overlay.Notify.Sound
}
}
if overlay.Sound != nil {
if base.Sound == nil {
base.Sound = &SoundConfig{}
}
base.Sound.Enabled = overlay.Sound.Enabled
if overlay.Sound.File != "" {
base.Sound.File = overlay.Sound.File
}
}
if overlay.Brightness != nil {
base.Brightness = &BrightnessConfig{
Enabled: overlay.Brightness.Enabled,
Value: overlay.Brightness.Value,
}
}
if overlay.Mute != nil {
base.Mute = &MuteConfig{
Enabled: overlay.Mute.Enabled,
}
}
if overlay.Custom != nil {
base.Custom = &CustomConfig{
Enabled: overlay.Custom.Enabled,
Pre: append([]string(nil), overlay.Custom.Pre...),
Post: append([]string(nil), overlay.Custom.Post...),
}
}
if overlay.Script != nil {
base.Script = &ScriptConfig{
Enabled: overlay.Script.Enabled,
Path: overlay.Script.Path,
}
}
}
+57
View File
@@ -0,0 +1,57 @@
package config
func DefaultConfig() *Config {
return &Config{
Profiles: map[string]Profile{
"default": {
Modules: DefaultModules(),
},
},
}
}
func DefaultModules() *Modules {
return &Modules{
Workspace: &WorkspaceConfig{
Enabled: true,
Backend: "",
Workspace: 12,
},
Media: &MediaConfig{
Enabled: true,
Action: "stop",
},
Lock: &LockConfig{
Enabled: true,
Command: "",
},
Kill: &KillConfig{
Enabled: false,
Processes: nil,
},
Notify: &NotifyConfig{
Enabled: false,
Sound: "",
},
Sound: &SoundConfig{
Enabled: true,
File: "/usr/share/sounds/freedesktop/stereo/complete.oga",
},
Brightness: &BrightnessConfig{
Enabled: false,
Value: 0,
},
Mute: &MuteConfig{
Enabled: false,
},
Custom: &CustomConfig{
Enabled: false,
Pre: nil,
Post: nil,
},
Script: &ScriptConfig{
Enabled: false,
Path: "",
},
}
}
+74
View File
@@ -0,0 +1,74 @@
package config
type Config struct {
Profiles map[string]Profile `yaml:"profiles"`
}
type Profile struct {
Extends string `yaml:"extends,omitempty"`
Modules *Modules `yaml:"modules,omitempty"`
}
type Modules struct {
Workspace *WorkspaceConfig `yaml:"workspace,omitempty"`
Media *MediaConfig `yaml:"media,omitempty"`
Lock *LockConfig `yaml:"lock,omitempty"`
Kill *KillConfig `yaml:"kill,omitempty"`
Notify *NotifyConfig `yaml:"notify,omitempty"`
Sound *SoundConfig `yaml:"sound,omitempty"`
Brightness *BrightnessConfig `yaml:"brightness,omitempty"`
Mute *MuteConfig `yaml:"mute,omitempty"`
Custom *CustomConfig `yaml:"custom,omitempty"`
Script *ScriptConfig `yaml:"script,omitempty"`
}
type WorkspaceConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Backend string `yaml:"backend"` // auto, niri, hyprland, kde
Workspace int `yaml:"workspace"`
}
type MediaConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Action string `yaml:"action"` // stop, pause, play-pause, next, previous, none
}
type LockConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Command string `yaml:"command"` // custom lock cmd, empty = auto
}
type KillConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Processes []string `yaml:"processes"`
}
type NotifyConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
Sound string `yaml:"sound"` // path or empty
}
type SoundConfig struct {
Enabled bool `yaml:"enabled" default:"true"`
File string `yaml:"file"`
}
type BrightnessConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Value int `yaml:"value"` // 0-100
}
type MuteConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
}
type CustomConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Pre []string `yaml:"pre"`
Post []string `yaml:"post"`
}
type ScriptConfig struct {
Enabled bool `yaml:"enabled" default:"false"`
Path string `yaml:"path"`
}