// Package hook runs shell commands after monitor layout changes. // Hooks execute asynchronously with a timeout; failures are logged // but never interrupt the daemon's operation. package hook import ( "context" "fmt" "log/slog" "os" "os/exec" "strings" "time" ) const defaultTimeout = 30 * time.Second // Runner executes shell commands from the hooks configuration. type Runner struct { logger *slog.Logger } // NewRunner creates a hook runner with the given logger. func NewRunner(logger *slog.Logger) *Runner { return &Runner{logger: logger} } // Run executes a list of shell commands concurrently. Each command gets // a separate sub-shell via "sh -c". If a command exceeds defaultTimeout, // it is killed. Errors are logged but never returned. // // Commands undergo basic shell-like expansion for tildes and environment // variables before execution. func (r *Runner) Run(ctx context.Context, commands []string) { for _, cmdStr := range commands { cmdStr = strings.TrimSpace(cmdStr) if cmdStr == "" { continue } go func(cmdStr string) { if err := r.runOne(ctx, cmdStr); err != nil { r.logger.Error("hook failed", "command", cmdStr, "error", err) } }(cmdStr) } } // runOne executes a single command with a timeout. func (r *Runner) runOne(ctx context.Context, cmdStr string) error { cmdStr = expandPath(cmdStr) cmdCtx, cancel := context.WithTimeout(ctx, defaultTimeout) defer cancel() r.logger.Debug("running hook", "command", cmdStr) cmd := exec.CommandContext(cmdCtx, "sh", "-c", cmdStr) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { return fmt.Errorf("hook: %w", err) } return nil } // expandPath performs basic tilde and $HOME expansion. func expandPath(s string) string { home, err := os.UserHomeDir() if err != nil { return s } s = strings.ReplaceAll(s, "~", home) s = strings.ReplaceAll(s, "$HOME", home) s = strings.ReplaceAll(s, "${HOME}", home) return s }