1// Package config reads nefercap's persistent settings.
2package config
3
4import (
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "slices"
11 "strings"
12 "syscall"
13)
14
15const (
16 maxBytes = 4096
17 maxPath = 4096
18)
19
20// ScreenshotOutput says where an interactive screenshot goes.
21type ScreenshotOutput string
22
23const (
24 OutputFile ScreenshotOutput = "file"
25 OutputFileClipboard ScreenshotOutput = "file+clipboard"
26 OutputClipboard ScreenshotOutput = "clipboard"
27)
28
29// Settings holds the persistent configuration. G toggles Grid for the current
30// selection without changing the saved setting. The directories are absolute,
31// or empty for the XDG default.
32type Settings struct {
33 Grid bool
34 ScreenshotDir string
35 VideoDir string
36 Output ScreenshotOutput
37}
38
39const knownKeys = "selector.grid, screenshot.dir, screenshot.output, video.dir"
40
41// Load reads $XDG_CONFIG_HOME/nefercap/config (or ~/.config/nefercap/config).
42// An absent file or setting enables the full monitor grid. Invalid settings
43// fail explicitly rather than silently changing the requested appearance.
44func Load() (Settings, error) {
45 settings := Settings{Grid: true, Output: OutputFile}
46 root := os.Getenv("XDG_CONFIG_HOME")
47 if !filepath.IsAbs(root) {
48 home, err := os.UserHomeDir()
49 if err != nil {
50 return settings, nil
51 }
52 root = filepath.Join(home, ".config")
53 }
54 path := filepath.Join(root, "nefercap", "config")
55 // Nonblocking open also protects against a FIFO substituted before Stat.
56 file, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0)
57 if errors.Is(err, os.ErrNotExist) {
58 return settings, nil
59 }
60 if err != nil {
61 return Settings{}, fmt.Errorf("open config: %w", err)
62 }
63 defer file.Close()
64 info, err := file.Stat()
65 if err != nil {
66 return Settings{}, fmt.Errorf("stat config: %w", err)
67 }
68 if !info.Mode().IsRegular() {
69 return Settings{}, fmt.Errorf("config %s: expected a regular file", path)
70 }
71 data, err := io.ReadAll(io.LimitReader(file, maxBytes+1))
72 if err != nil {
73 return Settings{}, fmt.Errorf("read config %s: %w", path, err)
74 }
75 if len(data) > maxBytes {
76 return Settings{}, fmt.Errorf("config %s exceeds %d bytes", path, maxBytes)
77 }
78 var seen [4]bool
79 for index, raw := range strings.Split(strings.TrimPrefix(string(data), "\ufeff"), "\n") {
80 line := strings.TrimSpace(raw)
81 if line == "" || strings.HasPrefix(line, "#") {
82 continue
83 }
84 key, value, ok := strings.Cut(line, "=")
85 key, value = strings.TrimSpace(key), strings.TrimSpace(value)
86 slot := -1
87 if ok {
88 slot = slices.Index(keys[:], key)
89 }
90 if slot < 0 || seen[slot] {
91 return Settings{}, fmt.Errorf("config %s line %d: expected each of %s at most once as key = value", path, index+1, knownKeys)
92 }
93 seen[slot] = true
94 if err := apply(&settings, key, value); err != nil {
95 return Settings{}, fmt.Errorf("config %s line %d: %w", path, index+1, err)
96 }
97 }
98 return settings, nil
99}
100
101var keys = [...]string{"selector.grid", "screenshot.dir", "screenshot.output", "video.dir"}
102
103func apply(s *Settings, key, value string) (err error) {
104 switch key {
105 case "selector.grid":
106 switch value {
107 case "on":
108 s.Grid = true
109 case "off":
110 s.Grid = false
111 default:
112 return errors.New("selector.grid must be on or off")
113 }
114 case "screenshot.output":
115 switch out := ScreenshotOutput(value); out {
116 case OutputFile, OutputFileClipboard, OutputClipboard:
117 s.Output = out
118 default:
119 return errors.New("screenshot.output must be file, file+clipboard or clipboard")
120 }
121 case "screenshot.dir":
122 s.ScreenshotDir, err = dir(key, value)
123 case "video.dir":
124 s.VideoDir, err = dir(key, value)
125 }
126 return err
127}
128
129// dir accepts an absolute path or one starting with ~/ (the home directory).
130func dir(key, value string) (string, error) {
131 if value == "" || len(value) > maxPath || strings.IndexByte(value, 0) >= 0 {
132 return "", fmt.Errorf("%s must be a non-empty path", key)
133 }
134 if strings.HasPrefix(value, "~/") {
135 home, err := os.UserHomeDir()
136 if err != nil || !filepath.IsAbs(home) {
137 return "", fmt.Errorf("%s: no absolute home directory for ~", key)
138 }
139 value = filepath.Join(home, value[1:])
140 }
141 if !filepath.IsAbs(value) {
142 return "", fmt.Errorf("%s must be an absolute path or start with ~/", key)
143 }
144 return filepath.Clean(value), nil
145}