1package noctalia
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "log/slog"
8 "os"
9 "path/filepath"
10 "sync"
11 "time"
12
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/fsnotify/fsnotify"
15)
16
17const defaultDebounceDelay = 75 * time.Millisecond
18
19// FileWatcher watches one Noctalia external theme file.
20//
21// It watches the file's parent directory so atomic writes and renames are
22// observed, then filters events to the configured file path.
23type FileWatcher struct {
24 mu sync.Mutex
25 delay time.Duration
26 watcher *fsnotify.Watcher
27 path string
28 stopCtx context.Context
29 stopFunc context.CancelFunc
30 running bool
31}
32
33// NewFileWatcher creates a file watcher with the default debounce delay.
34func NewFileWatcher() *FileWatcher {
35 return &FileWatcher{delay: defaultDebounceDelay}
36}
37
38// Start starts watching the configured external theme path.
39//
40// Calling Start with the same enabled path while already running is a no-op.
41// Calling Start with a different enabled path restarts the underlying watcher.
42// Disabled, unsupported, or empty-path config stops any existing watcher.
43func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
44 path, enabled, err := watchPathFromConfig(cfg)
45 if err != nil {
46 if stopErr := w.Stop(); stopErr != nil {
47 return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr))
48 }
49 return err
50 }
51 if !enabled || path == "" {
52 return w.Stop()
53 }
54 if onChange == nil {
55 return errors.New("noctalia file watcher callback is nil")
56 }
57
58 w.mu.Lock()
59 defer w.mu.Unlock()
60 if w.running && w.path == path {
61 return nil
62 }
63 if stopErr := w.stopLocked(); stopErr != nil {
64 return fmt.Errorf("stop previous noctalia theme watcher: %w", stopErr)
65 }
66
67 parent := filepath.Dir(path)
68 info, statErr := os.Stat(parent)
69 if statErr != nil {
70 return fmt.Errorf("watch noctalia theme parent directory: %w", statErr)
71 } else if !info.IsDir() {
72 return fmt.Errorf("watch noctalia theme parent directory: %s is not a directory", parent)
73 }
74
75 watcher, err := fsnotify.NewWatcher()
76 if err != nil {
77 return fmt.Errorf("create noctalia theme watcher: %w", err)
78 }
79 if err := watcher.Add(parent); err != nil {
80 if closeErr := watcher.Close(); closeErr != nil {
81 return errors.Join(
82 fmt.Errorf("watch noctalia theme parent directory: %w", err),
83 fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr),
84 )
85 }
86 return fmt.Errorf("watch noctalia theme parent directory: %w", err)
87 }
88
89 runCtx, cancel := context.WithCancel(ctx)
90 w.watcher = watcher
91 w.path = path
92 w.stopCtx = runCtx
93 w.stopFunc = cancel
94 w.running = true
95
96 go w.run(runCtx, watcher, path, onChange)
97 return nil
98}
99
100// Stop stops the current watcher. It is safe to call repeatedly.
101func (w *FileWatcher) Stop() error {
102 w.mu.Lock()
103 defer w.mu.Unlock()
104 return w.stopLocked()
105}
106
107func (w *FileWatcher) stopLocked() error {
108 if !w.running {
109 return nil
110 }
111 if w.stopFunc != nil {
112 w.stopFunc()
113 }
114 err := w.watcher.Close()
115 w.watcher = nil
116 w.path = ""
117 w.stopCtx = nil
118 w.stopFunc = nil
119 w.running = false
120 return err
121}
122
123func (w *FileWatcher) run(ctx context.Context, watcher *fsnotify.Watcher, path string, onChange func()) {
124 var timer *time.Timer
125 var timerC <-chan time.Time
126 defer func() {
127 if timer != nil {
128 timer.Stop()
129 }
130 w.mu.Lock()
131 if w.watcher == watcher {
132 _ = w.stopLocked()
133 }
134 w.mu.Unlock()
135 }()
136
137 for {
138 select {
139 case <-ctx.Done():
140 return
141 case event, ok := <-watcher.Events:
142 if !ok {
143 return
144 }
145 if !isRelevantFileEvent(event, path) {
146 continue
147 }
148 if timer == nil {
149 timer = time.NewTimer(w.delay)
150 timerC = timer.C
151 } else {
152 if !timer.Stop() {
153 select {
154 case <-timer.C:
155 default:
156 }
157 }
158 timer.Reset(w.delay)
159 timerC = timer.C
160 }
161 case err, ok := <-watcher.Errors:
162 if !ok {
163 return
164 }
165 slog.Warn("noctalia theme watcher error", "error", err)
166 case <-timerC:
167 timerC = nil
168 onChange()
169 }
170 }
171}
172
173func watchPathFromConfig(cfg entity.ExternalThemeConfig) (string, bool, error) {
174 provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
175 enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
176 if !enabled {
177 return "", false, nil
178 }
179 path, err := ExpandPath(cfg.Path)
180 if err != nil {
181 return "", false, err
182 }
183 return path, path != "", nil
184}
185
186func isRelevantFileEvent(event fsnotify.Event, path string) bool {
187 if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
188 return false
189 }
190 return filepath.Clean(event.Name) == path
191}
192
193func (w *FileWatcher) isRunningForTest() bool {
194 w.mu.Lock()
195 defer w.mu.Unlock()
196 return w.running
197}