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