79 lines
1.9 KiB
Go

// 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
}