1package daemon
2
3import (
4 "errors"
5 "fmt"
6 "maps"
7 "regexp"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/usecase/command"
14 "github.com/bnema/vev/internal/usecase/keys"
15 "github.com/bnema/vev/internal/usecase/theme"
16)
17
18var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
19
20// themeConfigSnapshot is published as one immutable value. Its zero value is
21// the default theme configuration: automatic mode with palette inheritance on.
22type themeConfigSnapshot struct {
23 mode domain.ThemeMode
24 paletteOff bool
25 accent domain.ThemeAccent
26}
27
28func (d *Daemon) storeThemeConfig(cfg domain.Config) {
29 snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
30 d.themeConfig.Store(&snapshot)
31}
32
33func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
34 snapshot := d.themeConfig.Load()
35 if snapshot == nil {
36 return themeConfigSnapshot{}
37 }
38 return *snapshot
39}
40
41// ApplyConfig validates and atomically swaps daemon runtime configuration.
42func (d *Daemon) ApplyConfig(cfg domain.Config) {
43 bindings, warnings := keys.BuildBindingEntries(cfg.BindingEntries)
44 overrides, codeWarnings := d.buildCodeOverrides(cfg.Codes)
45 allWarnings := append(append([]domain.Warning{}, warnings...), codeWarnings...)
46 for _, warning := range allWarnings {
47 d.logConfigWarning(warning)
48 }
49 allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
50 d.bindings.Store(bindings)
51 d.codeOverrides.Store(&overrides)
52 d.restoreProcessAllowlist.Store(&allowlist)
53 floating := cfg.Floating
54 d.floatingConfig.Store(&floating)
55 copyConfig := cfg.Copy
56 d.copyConfig.Store(©Config)
57 palette := cfg.Palette
58 d.paletteConfig.Store(&palette)
59 nav := cfg.Nav
60 d.navConfig.Store(&nav)
61 tabs := cfg.Tabs
62 d.tabsConfig.Store(&tabs)
63 d.storeThemeConfig(cfg)
64 barChanged := false
65 if d.barScripts != nil {
66 d.barScripts.mu.Lock()
67 d.barScripts.initLocked()
68 barCfg := barConfigFromDomain(cfg.Bar)
69 if d.barScripts.cfg != barCfg {
70 d.barScripts.cfg = barCfg
71 d.barScripts.outputs = make(map[domain.SessionID]barScriptOutputs)
72 d.barScripts.lastRefresh = make(map[domain.SessionID]time.Time)
73 d.barScripts.lastContext = make(map[domain.SessionID]barScriptContext)
74 d.barScripts.pending = make(map[domain.SessionID]bool)
75 d.barScripts.lastFailure = make(map[barScriptFailureKey]string)
76 d.barScripts.version++
77 barChanged = true
78 }
79 d.barScripts.mu.Unlock()
80 }
81 if barChanged {
82 d.signalBarPollerReload()
83 d.refreshBarScriptsAllSessions()
84 }
85 d.reapplyThemeAllSessions()
86 d.repaintAllAttachedClients()
87
88 if len(allWarnings) > 0 {
89 d.notify(nil, domain.NoticeWarn, domain.NoticeConfigReload,
90 configReloadNoticeMessage(allWarnings), configWarningsError(allWarnings))
91 }
92}
93
94func configReloadNoticeMessage(warnings []domain.Warning) string {
95 first := warnings[0]
96 if first.Line > 0 {
97 return fmt.Sprintf("config reloaded with %d warning(s): line %d: %s", len(warnings), first.Line, first.Msg)
98 }
99 return fmt.Sprintf("config reloaded with %d warning(s): %s", len(warnings), first.Msg)
100}
101
102// configWarningsError joins every config warning into a single error whose
103// message lists them all, so noticeDetails (which renders a cause's own
104// Error() string) surfaces every warning line in one notice's Details.
105func configWarningsError(warnings []domain.Warning) error {
106 lines := make([]string, len(warnings))
107 for i, w := range warnings {
108 if w.Line > 0 {
109 lines[i] = fmt.Sprintf("line %d: %s", w.Line, w.Msg)
110 } else {
111 lines[i] = w.Msg
112 }
113 }
114 return errors.New(strings.Join(lines, "; "))
115}
116
117func (d *Daemon) buildCodeOverrides(configured map[string]string) (map[string]string, []domain.Warning) {
118 commands := command.PaletteRegistry()
119 bySlug := make(map[string]command.Command, len(commands))
120 for _, cmd := range commands {
121 bySlug[cmd.Slug] = cmd
122 }
123
124 var warnings []domain.Warning
125 desired := make(map[string]string)
126 for _, slug := range sortedKeys(configured) {
127 if _, ok := bySlug[slug]; !ok {
128 warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("unknown command code slug %q", slug)})
129 continue
130 }
131 code := strings.ToUpper(strings.TrimSpace(configured[slug]))
132 if !commandCodePattern.MatchString(code) {
133 warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("invalid command code for %q", slug)})
134 continue
135 }
136 desired[slug] = code
137 }
138
139 // The effective code for a slug is its accepted override if it has one,
140 // or its registry default otherwise. Resolve to a fixpoint rather than a
141 // single sorted pass: dropping one slug's override to resolve a conflict
142 // can free up its default code, which may in turn conflict with a
143 // *different* slug's override — that must be re-checked regardless of
144 // where the two slugs fall in sort order.
145 accepted := make(map[string]string, len(desired))
146 maps.Copy(accepted, desired)
147 effectiveCode := func(slug string) string {
148 if code, ok := accepted[slug]; ok {
149 return code
150 }
151 return bySlug[slug].Code
152 }
153
154 for {
155 claimants := make(map[string][]string, len(commands))
156 for _, cmd := range commands {
157 code := effectiveCode(cmd.Slug)
158 claimants[code] = append(claimants[code], cmd.Slug)
159 }
160
161 dropped := false
162 conflictCodes := make([]string, 0, len(claimants))
163 for code, slugs := range claimants {
164 if len(slugs) > 1 {
165 conflictCodes = append(conflictCodes, code)
166 }
167 }
168 sort.Strings(conflictCodes)
169 for _, code := range conflictCodes {
170 slugs := claimants[code]
171 sort.Strings(slugs)
172
173 // If one claimant holds this code by default (no accepted
174 // override), every override claimant loses to it and is
175 // dropped. Otherwise every claimant is an override; keep the
176 // lexicographically smallest slug and drop the rest.
177 keep := ""
178 for _, slug := range slugs {
179 if _, isOverride := accepted[slug]; !isOverride {
180 keep = slug
181 break
182 }
183 }
184 if keep == "" {
185 keep = slugs[0]
186 }
187 for _, slug := range slugs {
188 if slug == keep {
189 continue
190 }
191 if _, isOverride := accepted[slug]; !isOverride {
192 continue
193 }
194 warnings = append(warnings, domain.Warning{Msg: fmt.Sprintf("command code %q for %q conflicts with %q", code, slug, keep)})
195 delete(accepted, slug)
196 dropped = true
197 }
198 }
199 if !dropped {
200 break
201 }
202 }
203 return accepted, warnings
204}
205
206func sortedKeys(values map[string]string) []string {
207 keys := make([]string, 0, len(values))
208 for key := range values {
209 keys = append(keys, key)
210 }
211 sort.Strings(keys)
212 return keys
213}
214
215func restoreProcessAllowlistFromConfig(cfg domain.SnapshotConfig) map[string]struct{} {
216 if cfg.RestoreProcessesSet {
217 return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
218 }
219 if len(cfg.RestoreProcesses) > 0 {
220 return buildRestoreProcessAllowlist(cfg.RestoreProcesses)
221 }
222 return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
223}
224
225func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
226 allow := make(map[string]struct{}, len(values))
227 for _, value := range values {
228 value = strings.TrimSpace(value)
229 if value == "" {
230 continue
231 }
232 allow[value] = struct{}{}
233 }
234 return allow
235}
236
237func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
238 allow := d.restoreProcessAllowlist.Load()
239 if allow == nil {
240 return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
241 }
242 return *allow
243}
244
245func (d *Daemon) logConfigWarning(w domain.Warning) {
246 if w.Line > 0 {
247 d.log.Warn("config warning", "line", w.Line, "msg", w.Msg)
248 return
249 }
250 d.log.Warn("config warning", "msg", w.Msg)
251}
252
253func (d *Daemon) currentCopyConfig() domain.CopyConfig {
254 if cfg := d.copyConfig.Load(); cfg != nil {
255 return *cfg
256 }
257 return domain.Defaults().Copy
258}
259
260func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
261 if cfg := d.paletteConfig.Load(); cfg != nil {
262 return *cfg
263 }
264 return domain.Defaults().Palette
265}
266
267func (d *Daemon) currentNavConfig() domain.NavConfig {
268 if cfg := d.navConfig.Load(); cfg != nil {
269 return *cfg
270 }
271 return domain.Defaults().Nav
272}
273
274func (d *Daemon) currentTabsConfig() domain.TabsConfig {
275 if cfg := d.tabsConfig.Load(); cfg != nil {
276 return *cfg
277 }
278 return domain.Defaults().Tabs
279}
280
281func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
282 if cfg := d.floatingConfig.Load(); cfg != nil {
283 return *cfg
284 }
285 return domain.Defaults().Floating
286}
287
288func (d *Daemon) codeOverrideSnapshot() map[string]string {
289 overrides := d.codeOverrides.Load()
290 if overrides == nil {
291 return nil
292 }
293 return *overrides
294}
295
296func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
297 if code, ok := overrides[cmd.Slug]; ok {
298 cmd.Code = code
299 }
300 return cmd
301}
302
303func (d *Daemon) commandByEffectiveCode(code string) (command.Command, bool) {
304 code = strings.ToUpper(strings.TrimSpace(code))
305 overrides := d.codeOverrideSnapshot()
306 for _, cmd := range command.PaletteRegistry() {
307 cmd = commandWithOverrides(cmd, overrides)
308 if cmd.Code == code {
309 return cmd, true
310 }
311 }
312 return command.Command{}, false
313}
314
315func effectiveThemeForConfig(clientTheme theme.Theme, config themeConfigSnapshot) theme.Theme {
316 switch config.mode {
317 case domain.ThemeDark:
318 return theme.BuiltinDark
319 case domain.ThemeLight:
320 return theme.BuiltinLight
321 default:
322 clientTheme.UsePalette = !config.paletteOff
323 return clientTheme
324 }
325}
326
327func (d *Daemon) effectiveTheme(clientTheme theme.Theme) theme.Theme {
328 return effectiveThemeForConfig(clientTheme, d.currentThemeConfig())
329}
330
331func (d *Daemon) resolveAppliedTheme(raw theme.Theme) appliedTheme {
332 config := d.currentThemeConfig()
333 effective := effectiveThemeForConfig(raw, config)
334 return appliedTheme{Raw: effective, Resolved: theme.Resolve(effective, config.accent)}
335}
336
337func (d *Daemon) reapplyThemeAllSessions() {
338 d.mu.Lock()
339 sessions := sessionsSnapshot(d.sessions)
340 d.mu.Unlock()
341
342 for _, sess := range sessions {
343 d.reapplyThemeSession(sess)
344 }
345}
346
347func (d *Daemon) reapplyThemeSession(sess *session) {
348 attachments := sess.snapshotAttachments()
349 if len(attachments) == 0 {
350 d.applyHostTheme(sess, nil, theme.Theme{}, true)
351 return
352 }
353 for _, ac := range attachments {
354 ac.sendMu.Lock()
355 sess.themeMu.Lock()
356 d.applyHostThemeLocked(sess, ac, ac.getClientTheme(), false)
357 sess.themeMu.Unlock()
358 ac.sendMu.Unlock()
359 }
360}