Skip to content

Commit c3b3bb3

Browse files
0bCdianclaude
andcommitted
feat(backend): expose wal-qt process env and awww daemon format
Add two per-backend escape hatches instead of a generic CLI-flag string, which would break on positional-arg setters and resist validation. wal-qt: backend.wal-qt.env, a list of KEY=VALUE entries merged into the wal-qt-host process environment at spawn. Stored as a list (not a map) because Viper lowercases map keys and env var names are case-sensitive. ValidateConfig rejects malformed entries and daemon-managed keys (WAYLAND_DISPLAY, XDG_RUNTIME_DIR, DISPLAY, PATH, HOME, LD_PRELOAD, LD_LIBRARY_PATH). This is the supported way to set QTWEBENGINE_CHROMIUM_FLAGS and other Qt/Chromium rendering knobs. awww: backend.awww.daemon_format, passed to `awww-daemon --format` (argb|abgr|rgb|bgr) at spawn; takes effect on the next daemon restart. Settings UI gets a key/value env editor for wal-qt (with a client-side protected-key warning) and a format dropdown for awww. Both fields are daemon-scoped, so the UI notes they apply after a backend restart. Also fixes a pre-existing tsc error: blur_nsfw_thumbnails missing from the hard-coded default Wallhaven config in settingsStore. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bcec18c commit c3b3bb3

11 files changed

Lines changed: 389 additions & 3 deletions

File tree

daemon/API_CONTRACT.md

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1349,12 +1349,15 @@ Returned by `GET /config/backends/awww`. Updated by `PATCH /config/backends/awww
13491349
"resize": "crop",
13501350
"fill_color": "000000",
13511351
"filter_type": "Lanczos3",
1352-
"invert_y": false
1352+
"invert_y": false,
1353+
"daemon_format": ""
13531354
}
13541355
```
13551356

13561357
TOML config supports both hyphens and underscores (e.g. `transition-type` and `transition_type` are equivalent).
13571358

1359+
`daemon_format` is passed to `awww-daemon --format` (`argb` | `abgr` | `rgb` | `bgr`; empty = awww default). It is read only when the daemon spawns `awww-daemon`, so it takes effect on the next awww-daemon restart, and only when waypaper started the daemon itself.
1360+
13581361
#### mpvpaper Backend Config
13591362

13601363
Returned by `GET /config/backends/mpvpaper`. Updated by `PATCH /config/backends/mpvpaper`.
@@ -1370,6 +1373,19 @@ Returned by `GET /config/backends/mpvpaper`. Updated by `PATCH /config/backends/
13701373
}
13711374
```
13721375

1376+
#### wal-qt Backend Config
1377+
1378+
Returned by `GET /config/backends/wal-qt`. Updated by `PATCH /config/backends/wal-qt`.
1379+
Only the fields relevant to process environment are shown here:
1380+
1381+
```json
1382+
{
1383+
"env": ["QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu --ignore-gpu-blocklist", "QSG_RHI_BACKEND=software"]
1384+
}
1385+
```
1386+
1387+
`env` is a list of `KEY=VALUE` entries merged into the `wal-qt` process environment at spawn — the escape hatch for Qt/Chromium rendering knobs. It is stored as a list (not a map) because Viper lowercases map keys and environment variable names are case-sensitive. `PATCH` rejects entries that are not in `KEY=VALUE` form and entries whose key is daemon-managed: `WAYLAND_DISPLAY`, `XDG_RUNTIME_DIR`, `DISPLAY`, `PATH`, `HOME`, `LD_PRELOAD`, `LD_LIBRARY_PATH`. Read only at spawn, so changes take effect on the next wal-qt restart.
1388+
13731389
#### MonitorsConfig
13741390

13751391
```json

daemon/internal/backend/awww/awww.go

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,21 @@ const (
2323
daemonBinary = "awww-daemon"
2424
)
2525

26+
// validDaemonFormats are the wl_shm formats accepted by `awww-daemon --format`.
27+
var validDaemonFormats = map[string]bool{"argb": true, "abgr": true, "rgb": true, "bgr": true}
28+
29+
// awwwDaemonArgs builds the argv for awww-daemon. --no-cache is always set:
30+
// waypaper drives wallpaper selection explicitly via `awww img`, so the daemon
31+
// must not restore its own cache on startup. format, when non-empty, adds
32+
// `--format <value>`.
33+
func awwwDaemonArgs(format string) []string {
34+
args := []string{"--no-cache"}
35+
if format != "" {
36+
args = append(args, "--format", format)
37+
}
38+
return args
39+
}
40+
2641
type Awww struct {
2742
once sync.Once
2843
v *viper.Viper
@@ -80,8 +95,13 @@ func (a *Awww) Initialize(ctx context.Context) error {
8095
return nil
8196
}
8297

83-
slog.Info("starting daemon with --no-cache", "binary", daemonBinary)
84-
cmd := exec.Command(daemonBinary, "--no-cache")
98+
format := ""
99+
if a.v != nil {
100+
format = strings.ToLower(strings.TrimSpace(a.v.GetString("backend.awww.daemon_format")))
101+
}
102+
args := awwwDaemonArgs(format)
103+
slog.Info("starting daemon", "binary", daemonBinary, "args", args)
104+
cmd := exec.Command(daemonBinary, args...)
85105
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
86106
if err := cmd.Start(); err != nil {
87107
return fmt.Errorf("awww: start %s: %w", daemonBinary, err)
@@ -239,6 +259,7 @@ func (a *Awww) RegisterDefaults(v *viper.Viper) {
239259
v.SetDefault("backend.awww.fill_color", "000000")
240260
v.SetDefault("backend.awww.filter_type", string(FilterLanczos3))
241261
v.SetDefault("backend.awww.invert_y", false)
262+
v.SetDefault("backend.awww.daemon_format", "")
242263
}
243264

244265
// loadConfigFromViper reads the [backend.awww] section from the TOML config via Viper.
@@ -333,5 +354,8 @@ func (a *Awww) ValidateConfig(raw json.RawMessage) error {
333354
if cfg.TransitionDuration < 0 || cfg.TransitionDuration > 120 {
334355
return fmt.Errorf("awww: transition_duration must be between 0 and 120 seconds")
335356
}
357+
if f := strings.ToLower(strings.TrimSpace(cfg.DaemonFormat)); f != "" && !validDaemonFormats[f] {
358+
return fmt.Errorf("awww: daemon_format must be one of argb, abgr, rgb, bgr")
359+
}
336360
return nil
337361
}

daemon/internal/backend/awww/config.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ type Config struct {
1313
FillColor string `mapstructure:"fill_color" json:"fill_color"`
1414
FilterType FilterType `mapstructure:"filter_type" json:"filter_type"`
1515
InvertY bool `mapstructure:"invert_y" json:"invert_y"`
16+
// DaemonFormat, when non-empty, is passed to `awww-daemon --format`
17+
// (argb|abgr|rgb|bgr) — a wl_shm format. Empty leaves awww-daemon on its
18+
// own default (argb). Read only at Initialize, so it takes effect on the
19+
// next awww-daemon restart, and only when waypaper spawned the daemon
20+
// itself (a pre-existing awww-daemon is left untouched).
21+
DaemonFormat string `mapstructure:"daemon_format" json:"daemon_format"`
1622
}
1723

1824
type TransitionType string
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package awww
2+
3+
import (
4+
"slices"
5+
"testing"
6+
)
7+
8+
func TestAwwwDaemonArgs(t *testing.T) {
9+
if got := awwwDaemonArgs(""); !slices.Equal(got, []string{"--no-cache"}) {
10+
t.Fatalf("awwwDaemonArgs(\"\") = %v, want [--no-cache]", got)
11+
}
12+
got := awwwDaemonArgs("rgb")
13+
want := []string{"--no-cache", "--format", "rgb"}
14+
if !slices.Equal(got, want) {
15+
t.Fatalf("awwwDaemonArgs(\"rgb\") = %v, want %v", got, want)
16+
}
17+
}
18+
19+
func TestValidateConfigDaemonFormat(t *testing.T) {
20+
b := New()
21+
for _, f := range []string{"", "argb", "abgr", "rgb", "bgr"} {
22+
if err := b.ValidateConfig([]byte(`{"daemon_format":"` + f + `"}`)); err != nil {
23+
t.Fatalf("daemon_format %q rejected: %v", f, err)
24+
}
25+
}
26+
if err := b.ValidateConfig([]byte(`{"daemon_format":"xrgb"}`)); err == nil {
27+
t.Fatal("invalid daemon_format accepted, want rejection")
28+
}
29+
}

daemon/internal/backend/walqt/config.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ type Config struct {
5252
FillColor string `mapstructure:"fill_color" json:"fill_color"`
5353
VideoAudioDefault bool `mapstructure:"video_audio_default" json:"video_audio_default"`
5454
AllowNetworkWallpapers bool `mapstructure:"allow_network_wallpapers" json:"allow_network_wallpapers"`
55+
// Env holds extra environment variables ("KEY=VALUE" each) merged into the
56+
// wal-qt process environment at spawn — the escape hatch for Qt/Chromium
57+
// rendering knobs (QTWEBENGINE_CHROMIUM_FLAGS, QSG_RHI_BACKEND, …) on hosts
58+
// without a usable GPU. Protected keys (see protectedEnvKeys) are rejected
59+
// by ValidateConfig. Stored as a list, not a map, because Viper lowercases
60+
// map keys and env var names are case-sensitive. Takes effect on the next
61+
// wal-qt (re)spawn.
62+
Env []string `mapstructure:"env" json:"env"`
5563
}
5664

5765
func defaultSocketPath() string {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package walqt
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
)
7+
8+
// protectedEnvKeys are environment variables the daemon owns. The user may not
9+
// override them through backend.wal-qt.env: doing so silently breaks the
10+
// wal-qt spawn (wrong Wayland socket, missing binary, wrong home dir, injected
11+
// libraries) with no obvious cause. Everything else is allowed — the env map
12+
// is an escape hatch, so it stays wide open apart from these.
13+
var protectedEnvKeys = map[string]bool{
14+
"WAYLAND_DISPLAY": true,
15+
"XDG_RUNTIME_DIR": true,
16+
"DISPLAY": true,
17+
"PATH": true,
18+
"HOME": true,
19+
"LD_PRELOAD": true,
20+
"LD_LIBRARY_PATH": true,
21+
}
22+
23+
// validateEnvEntries checks that every entry is a well-formed "KEY=VALUE" pair
24+
// with a non-empty, whitespace-free key that is not in protectedEnvKeys.
25+
func validateEnvEntries(entries []string) error {
26+
for _, e := range entries {
27+
key, _, ok := strings.Cut(e, "=")
28+
if !ok {
29+
return fmt.Errorf("env entry %q must be in KEY=VALUE form", e)
30+
}
31+
key = strings.TrimSpace(key)
32+
if key == "" {
33+
return fmt.Errorf("env entry %q has an empty key", e)
34+
}
35+
if strings.ContainsAny(key, " \t") {
36+
return fmt.Errorf("env key %q must not contain whitespace", key)
37+
}
38+
if protectedEnvKeys[strings.ToUpper(key)] {
39+
return fmt.Errorf("%s is managed by waypaper and cannot be overridden", key)
40+
}
41+
}
42+
return nil
43+
}
44+
45+
// mergeProcessEnv returns base with the extra "KEY=VALUE" entries appended.
46+
// os/exec deduplicates Cmd.Env keeping the last occurrence of each key, so
47+
// appending extra after the inherited environment makes extra entries win.
48+
// Blank entries are skipped.
49+
func mergeProcessEnv(base, extra []string) []string {
50+
if len(extra) == 0 {
51+
return base
52+
}
53+
merged := make([]string, 0, len(base)+len(extra))
54+
merged = append(merged, base...)
55+
for _, e := range extra {
56+
if strings.TrimSpace(e) == "" {
57+
continue
58+
}
59+
merged = append(merged, e)
60+
}
61+
return merged
62+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package walqt
2+
3+
import (
4+
"slices"
5+
"testing"
6+
)
7+
8+
func TestValidateEnvEntries(t *testing.T) {
9+
cases := []struct {
10+
name string
11+
entries []string
12+
wantErr bool
13+
}{
14+
{"nil", nil, false},
15+
{"valid chromium flags", []string{"QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu --ignore-gpu-blocklist"}, false},
16+
{"valid multiple", []string{"QSG_RHI_BACKEND=software", "LIBGL_ALWAYS_SOFTWARE=1"}, false},
17+
{"empty value allowed", []string{"FOO="}, false},
18+
{"missing equals", []string{"NOEQUALS"}, true},
19+
{"empty key", []string{"=value"}, true},
20+
{"whitespace in key", []string{"BAD KEY=v"}, true},
21+
{"protected WAYLAND_DISPLAY", []string{"WAYLAND_DISPLAY=wayland-9"}, true},
22+
{"protected key case-insensitive", []string{"path=/evil"}, true},
23+
{"protected LD_PRELOAD", []string{"LD_PRELOAD=/tmp/x.so"}, true},
24+
}
25+
for _, tc := range cases {
26+
t.Run(tc.name, func(t *testing.T) {
27+
err := validateEnvEntries(tc.entries)
28+
if (err != nil) != tc.wantErr {
29+
t.Fatalf("validateEnvEntries(%v) err = %v, wantErr %v", tc.entries, err, tc.wantErr)
30+
}
31+
})
32+
}
33+
}
34+
35+
func TestMergeProcessEnv(t *testing.T) {
36+
base := []string{"PATH=/usr/bin", "HOME=/home/u"}
37+
38+
if got := mergeProcessEnv(base, nil); !slices.Equal(got, base) {
39+
t.Fatalf("empty extra: got %v, want %v", got, base)
40+
}
41+
42+
got := mergeProcessEnv(base, []string{"FOO=bar", " ", "BAZ=qux"})
43+
want := []string{"PATH=/usr/bin", "HOME=/home/u", "FOO=bar", "BAZ=qux"}
44+
if !slices.Equal(got, want) {
45+
t.Fatalf("merge: got %v, want %v", got, want)
46+
}
47+
}
48+
49+
func TestValidateConfigRejectsProtectedEnv(t *testing.T) {
50+
b := New()
51+
if err := b.ValidateConfig([]byte(`{"env":["QT_SCALE_FACTOR=2"]}`)); err != nil {
52+
t.Fatalf("valid env rejected: %v", err)
53+
}
54+
if err := b.ValidateConfig([]byte(`{"env":["PATH=/evil"]}`)); err == nil {
55+
t.Fatal("protected env key accepted, want rejection")
56+
}
57+
}

daemon/internal/backend/walqt/walqt.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,7 @@ func (w *WalQt) initializeImpl(ctx context.Context) error {
198198
"DISPLAY", os.Getenv("DISPLAY"),
199199
)
200200
cmd := exec.Command(binaryName)
201+
cmd.Env = mergeProcessEnv(os.Environ(), cfg.Env)
201202
cmd.SysProcAttr = &syscall.SysProcAttr{Pdeathsig: syscall.SIGTERM}
202203
// Pipe stdout/stderr through the daemon logger so spawn failures are visible.
203204
cmd.Stdout = &slogWriter{prefix: "wal-qt stdout"}
@@ -597,6 +598,7 @@ func (w *WalQt) RegisterDefaults(v *viper.Viper) {
597598
v.SetDefault(viperBackendKey+".fill_color", def.FillColor)
598599
v.SetDefault(viperBackendKey+".video_audio_default", def.VideoAudioDefault)
599600
v.SetDefault(viperBackendKey+".allow_network_wallpapers", def.AllowNetworkWallpapers)
601+
v.SetDefault(viperBackendKey+".env", []string{})
600602
}
601603

602604
func (w *WalQt) ValidateConfig(raw json.RawMessage) error {
@@ -630,6 +632,9 @@ func (w *WalQt) ValidateConfig(raw json.RawMessage) error {
630632
return fmt.Errorf("wal-qt: fill_color must be 6- or 8-digit hex (RRGGBB or RRGGBBAA), got %q", cfg.FillColor)
631633
}
632634
}
635+
if err := validateEnvEntries(cfg.Env); err != nil {
636+
return fmt.Errorf("wal-qt: %w", err)
637+
}
633638
return nil
634639
}
635640

@@ -734,6 +739,7 @@ func (w *WalQt) loadConfigFromViper() *Config {
734739
}
735740
cfg.VideoAudioDefault = getBool("video_audio_default")
736741
cfg.AllowNetworkWallpapers = getBool("allow_network_wallpapers")
742+
cfg.Env = w.v.GetStringSlice(viperBackendKey + ".env")
737743

738744
if w.v != nil {
739745
cfg.TransitionAngleDeg = normalizeAngleDeg(intFromViperPrefixes(w.v, "transition_angle_deg", cfg.TransitionAngleDeg))

electron/daemon-go-types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,8 @@ export interface AwwwConfig {
333333
fill_color: string;
334334
filter_type: string;
335335
invert_y: boolean;
336+
/** wl_shm format for `awww-daemon --format` (argb|abgr|rgb|bgr). Empty = awww default. Applies on next awww-daemon restart. */
337+
daemon_format?: string;
336338
}
337339

338340
export interface FehConfig {
@@ -399,6 +401,8 @@ export interface WalQtConfig {
399401
video_audio_default?: boolean;
400402
/** When true, HTML wallpapers may use fetch/XHR to the network (synced to wal-qt at runtime). */
401403
allow_network_wallpapers?: boolean;
404+
/** Extra environment variables ("KEY=VALUE" each) merged into the wal-qt process at spawn — e.g. QTWEBENGINE_CHROMIUM_FLAGS. Applies on next wal-qt restart. */
405+
env?: string[];
402406
}
403407

404408
export interface MonitorsConfig {

0 commit comments

Comments
 (0)