Files
gosleep/main.go
T
forust 8501fd074c 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
2026-07-03 01:43:22 +02:00

349 lines
7.5 KiB
Go

package main
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
"github.com/forust/gosleep-timer/config"
"github.com/forust/gosleep-timer/engine"
"github.com/forust/gosleep-timer/history"
"github.com/forust/gosleep-timer/modules"
"github.com/forust/gosleep-timer/tui"
// Register all modules
_ "github.com/forust/gosleep-timer/modules/brightness"
_ "github.com/forust/gosleep-timer/modules/custom"
_ "github.com/forust/gosleep-timer/modules/kill"
_ "github.com/forust/gosleep-timer/modules/lock"
_ "github.com/forust/gosleep-timer/modules/media"
_ "github.com/forust/gosleep-timer/modules/mute"
_ "github.com/forust/gosleep-timer/modules/notify"
_ "github.com/forust/gosleep-timer/modules/script"
_ "github.com/forust/gosleep-timer/modules/sound"
_ "github.com/forust/gosleep-timer/modules/workspace"
)
var (
cfgFile string
profile string
cliMode bool
quiet bool
)
var rootCmd = &cobra.Command{
Use: "gosleep-timer",
Short: "TUI sleep timer with modular WM/DE support",
Long: `gosleep-timer — configurable timer with modules for workspace switching,
media control, screen locking, process killing, notifications, and more.
Supports niri, Hyprland, KDE, and custom commands.`,
RunE: func(cmd *cobra.Command, args []string) error {
// Init modules registry
modules.Init()
// Load config
cfgPath := cfgFile
if cfgPath == "" {
var err error
cfgPath, err = config.DefaultConfigPath()
if err != nil {
return fmt.Errorf("config path: %w", err)
}
}
cfg, err := config.LoadOrCreate(cfgPath)
if err != nil {
return fmt.Errorf("config: %w", err)
}
// CLI mode (run without TUI)
if cliMode {
return runCLI(cfg, args)
}
// Validate modules
if errs := modules.ValidateAll(); len(errs) > 0 {
for _, e := range errs {
fmt.Fprintf(os.Stderr, "⚠ module warning: %v\n", e)
}
}
// Start TUI
m := tui.NewModel(cfg)
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("tui error: %w", err)
}
return nil
},
}
var listProfilesCmd = &cobra.Command{
Use: "list-profiles",
Short: "List available profiles",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
for name := range cfg.Profiles {
p := cfg.Profiles[name]
extends := ""
if p.Extends != "" {
extends = fmt.Sprintf(" (extends: %s)", p.Extends)
}
fmt.Printf(" %s%s\n", name, extends)
}
return nil
},
}
var exportCmd = &cobra.Command{
Use: "export",
Short: "Export config to stdout",
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
data, err := config.SaveToString(cfg)
if err != nil {
return err
}
fmt.Print(data)
return nil
},
}
var importCmd = &cobra.Command{
Use: "import < file",
Short: "Import config from stdin",
RunE: func(cmd *cobra.Command, args []string) error {
cfgPath, err := getConfigPath()
if err != nil {
return err
}
data, err := os.ReadFile("/dev/stdin")
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
var cfg config.Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parse config: %w", err)
}
if err := config.Save(cfgPath, &cfg); err != nil {
return fmt.Errorf("save config: %w", err)
}
fmt.Printf("Config imported to %s\n", cfgPath)
return nil
},
}
var historyCmd = &cobra.Command{
Use: "history",
Short: "Show timer history",
RunE: func(cmd *cobra.Command, args []string) error {
path, err := history.DefaultPath()
if err != nil {
return err
}
records, err := history.ReadAll(path)
if err != nil {
return err
}
if len(records) == 0 {
fmt.Println("No history.")
return nil
}
for _, r := range records {
errStr := ""
if r.Error != "" {
errStr = fmt.Sprintf(" error=%s", r.Error)
}
fmt.Printf("%s | %-8s | %-10s | %s%s\n",
r.Timestamp.Format("2006-01-02 15:04:05"),
r.Duration,
r.Status,
r.Profile,
errStr,
)
}
return nil
},
}
var statsCmd = &cobra.Command{
Use: "stats",
Short: "Show timer statistics",
RunE: func(cmd *cobra.Command, args []string) error {
path, err := history.DefaultPath()
if err != nil {
return err
}
records, err := history.ReadAll(path)
if err != nil {
return err
}
s := history.Calculate(records)
fmt.Printf("Total runs: %d\n", s.TotalRuns)
fmt.Printf("Completed: %d\n", s.CompletedRuns)
fmt.Printf("Killed: %d\n", s.KilledRuns)
fmt.Printf("Errors: %d\n", s.ErrorRuns)
return nil
},
}
func runCLI(cfg *config.Config, args []string) error {
duration := "25m"
if len(args) > 0 {
duration = args[0]
}
d, err := engine.ParseDuration(duration)
if err != nil {
return fmt.Errorf("invalid duration %q: %w", duration, err)
}
profileName := profile
if profileName == "" {
profileName = "default"
}
p, ok := cfg.Profiles[profileName]
if !ok {
return fmt.Errorf("profile %q not found", profileName)
}
modCfg := p.Modules
if modCfg == nil {
modCfg = config.DefaultModules()
}
modCtx := &modules.ModuleContext{
Context: context.Background(),
Profile: profileName,
Duration: duration,
Config: modCfg,
}
preCmds := modules.CollectPreCmds(modCtx)
postCmds := modules.CollectPostCmds(modCtx)
if !quiet {
fmt.Printf("Timer: %s | Profile: %s\n", duration, profileName)
fmt.Printf("Pre: %s\n", strings.Join(preCmds, "; "))
}
t := engine.NewTimer(d)
sr := engine.NewStageRunner(t, preCmds, postCmds)
// Handle signals
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go func() {
<-sigCh
sr.Stop()
cancel()
if !quiet {
fmt.Println("\n⛔ Timer killed")
}
}()
go sr.Run(ctx)
for evt := range sr.Events {
if evt.Done {
break
}
if evt.Stage == engine.StageTimer && !quiet {
rem := evt.Remaining.Round(time.Second)
fmt.Printf("\r⏱ %s remaining...", rem)
}
}
if !quiet {
fmt.Println("\n✅ Timer completed!")
}
// Record history
histPath, err := history.DefaultPath()
if err == nil {
if l, err := history.Open(histPath); err == nil {
l.Append(history.Record{
Profile: profileName,
Duration: duration,
Status: "completed",
})
l.Close()
}
}
return nil
}
func loadConfig() (*config.Config, error) {
path, err := getConfigPath()
if err != nil {
return nil, err
}
return config.LoadOrCreate(path)
}
func getConfigPath() (string, error) {
if cfgFile != "" {
return cfgFile, nil
}
return config.DefaultConfigPath()
}
func init() {
rootCmd.PersistentFlags().StringVarP(&cfgFile, "config", "c", "", "config file path")
rootCmd.PersistentFlags().BoolVarP(&quiet, "quiet", "q", false, "suppress output")
runCmd := &cobra.Command{
Use: "run [duration]",
Short: "Run timer in CLI mode (no TUI)",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cliMode = true
cfg, err := loadConfig()
if err != nil {
return err
}
return runCLI(cfg, args)
},
}
runCmd.Flags().StringVarP(&profile, "profile", "p", "default", "profile name")
rootCmd.AddCommand(runCmd)
rootCmd.AddCommand(listProfilesCmd)
rootCmd.AddCommand(exportCmd)
rootCmd.AddCommand(importCmd)
rootCmd.AddCommand(historyCmd)
rootCmd.AddCommand(statsCmd)
}
func main() {
if err := rootCmd.Execute(); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}