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
+42
View File
@@ -0,0 +1,42 @@
package engine
import (
"context"
"os/exec"
"time"
)
type ExecResult struct {
Cmd string
Output string
Err error
Elapsed time.Duration
}
func Execute(ctx context.Context, cmd string, timeout time.Duration) ExecResult {
start := time.Now()
var cancel context.CancelFunc
if timeout > 0 {
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
out, err := exec.CommandContext(ctx, "bash", "-c", cmd).CombinedOutput()
elapsed := time.Since(start)
return ExecResult{
Cmd: cmd,
Output: string(out),
Err: err,
Elapsed: elapsed,
}
}
func ExecuteAll(ctx context.Context, cmds []string, timeout time.Duration) []ExecResult {
results := make([]ExecResult, len(cmds))
for i, cmd := range cmds {
results[i] = Execute(ctx, cmd, timeout)
}
return results
}
+74
View File
@@ -0,0 +1,74 @@
package engine
import (
"context"
"time"
)
type StageRunner struct {
Timer *Timer
PreCmds []string
PostCmds []string
Events chan TimerEvent
ExecTimeout time.Duration
}
func NewStageRunner(timer *Timer, pre, post []string) *StageRunner {
return &StageRunner{
Timer: timer,
PreCmds: pre,
PostCmds: post,
Events: make(chan TimerEvent, 100),
ExecTimeout: 30 * time.Second,
}
}
func (sr *StageRunner) Run(ctx context.Context) {
defer close(sr.Events)
// PreCmds
for _, cmd := range sr.PreCmds {
result := Execute(ctx, cmd, sr.ExecTimeout)
if result.Err != nil {
sr.Events <- TimerEvent{Stage: StagePre, Status: StatusError, Done: true}
return
}
sr.Events <- TimerEvent{Stage: StagePre, Status: StatusRunning}
}
// Timer
timerEvents := make(chan TimerEvent, 100)
go sr.Timer.Start(ctx, timerEvents)
timerDone := false
timerLoop:
for {
select {
case ev := <-timerEvents:
sr.Events <- ev
if ev.Done {
timerDone = true
break timerLoop
}
case <-ctx.Done():
break timerLoop
}
}
// PostCmds (only on normal timer completion)
if timerDone && sr.Timer.Status == StatusDone {
for _, cmd := range sr.PostCmds {
result := Execute(ctx, cmd, sr.ExecTimeout)
if result.Err != nil {
sr.Events <- TimerEvent{Stage: StagePost, Status: StatusError, Done: true}
return
}
sr.Events <- TimerEvent{Stage: StagePost, Status: StatusRunning}
}
sr.Events <- TimerEvent{Status: StatusDone, Done: true}
}
}
func (sr *StageRunner) Stop() {
sr.Timer.Stop()
}
+116
View File
@@ -0,0 +1,116 @@
package engine
import (
"context"
"fmt"
"regexp"
"strconv"
"strings"
"time"
)
type Status int
const (
StatusIdle Status = iota
StatusRunning
StatusPaused
StatusDone
StatusKilled
StatusError
)
type StageType string
const (
StagePre StageType = "pre"
StageTimer StageType = "timer"
StagePost StageType = "post"
)
type TimerEvent struct {
Remaining time.Duration
Total time.Duration
Stage StageType
Status Status
Done bool
}
type Timer struct {
Duration time.Duration
Status Status
StartedAt time.Time
elapsed time.Duration
cancel context.CancelFunc
}
func ParseDuration(s string) (time.Duration, error) {
s = strings.TrimSpace(s)
if d, err := time.ParseDuration(s); err == nil {
return d, nil
}
re := regexp.MustCompile(`(\d+)([hms])`)
matches := re.FindAllStringSubmatch(s, -1)
if len(matches) == 0 {
return 0, fmt.Errorf("invalid duration: %s", s)
}
var d time.Duration
for _, m := range matches {
v, _ := strconv.Atoi(m[1])
switch m[2] {
case "h":
d += time.Duration(v) * time.Hour
case "m":
d += time.Duration(v) * time.Minute
case "s":
d += time.Duration(v) * time.Second
}
}
return d, nil
}
func NewTimer(d time.Duration) *Timer {
return &Timer{Duration: d, Status: StatusIdle}
}
func (t *Timer) Start(ctx context.Context, events chan<- TimerEvent) {
ctx, cancel := context.WithCancel(ctx)
t.cancel = cancel
t.StartedAt = time.Now()
t.Status = StatusRunning
go func() {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
t.Status = StatusKilled
events <- TimerEvent{Status: StatusKilled, Done: true}
return
case <-ticker.C:
elapsed := time.Since(t.StartedAt)
remaining := t.Duration - elapsed
if remaining <= 0 {
t.Status = StatusDone
events <- TimerEvent{Remaining: 0, Total: t.Duration, Status: StatusDone, Done: true}
return
}
events <- TimerEvent{
Remaining: remaining,
Total: t.Duration,
Stage: StageTimer,
Status: StatusRunning,
}
}
}
}()
}
func (t *Timer) Stop() {
if t.cancel != nil {
t.cancel()
}
t.Status = StatusKilled
}