diff --git a/core/cmd/dms/commands_icc.go b/core/cmd/dms/commands_icc.go index 1e83129b4..de14e190d 100644 --- a/core/cmd/dms/commands_icc.go +++ b/core/cmd/dms/commands_icc.go @@ -1,7 +1,6 @@ package main import ( - "bytes" "encoding/json" "fmt" "os" @@ -268,114 +267,34 @@ func runICCInfo(cmd *cobra.Command, args []string) { func runICCApply(cmd *cobra.Command, args []string) { outputName := args[0] - filePath := args[1] - - // Resolve to absolute path - if !filepath.IsAbs(filePath) { - abs, err := filepath.Abs(filePath) - if err != nil { - log.Fatalf("Failed to resolve path: %v", err) - } - filePath = abs + filePath, err := filepath.Abs(args[1]) + if err != nil { + log.Fatalf("Failed to resolve path: %v", err) } - // Validate file exists and is a valid ICC profile. The rules the daemon - // enforces are checked here too: a profile it would refuse must not be copied - // into the profile directory first, or `dms icc list` keeps offering a file - // that can never be applied. profile, err := icc.ParseFile(filePath) if err != nil { log.Fatalf("Invalid ICC profile: %v", err) } - if profile.ColorSpace != "RGB" { - log.Fatalf("Unsupported color space %q, only RGB profiles can be applied", profile.ColorSpace) - } - if !profile.HasVCGT { - log.Fatalf("%s has no vcgt table, so it has no gamma ramp to apply", filepath.Base(filePath)) - } - // Copy the ICC file to config dir for persistence - configDir := getICCConfigDir() - if err := os.MkdirAll(configDir, 0755); err != nil { - log.Fatalf("Failed to create config directory: %v", err) - } - - destPath, copied, err := stageProfile(configDir, filePath) - if err != nil { - log.Fatalf("Failed to copy ICC file: %v", err) - } - - // Send IPC request to running server req := ipc.Request{ Method: "wayland.icc.apply", Params: map[string]any{ "output": outputName, - "path": destPath, + "path": filePath, }, } resp, err := sendServerRequest(req) if err != nil { - dropCopiedProfile(destPath, copied) log.Fatalf("Failed to apply ICC profile: %v", err) } if resp.Error != "" { - dropCopiedProfile(destPath, copied) log.Fatalf("Server error: %s", resp.Error) } fmt.Printf("Applied ICC profile '%s' to output '%s'\n", profile.Description, outputName) } -// stageProfile places filePath in configDir and reports the path the daemon -// should load and whether a new file was written. A file already in configDir -// is used as it is. An existing file with the same name keeps its content: an -// identical copy is reused, a different one gets a numbered sibling, so two -// displays never end up sharing one file by name. -func stageProfile(configDir, filePath string) (string, bool, error) { - if filepath.Dir(filepath.Clean(filePath)) == filepath.Clean(configDir) { - return filePath, false, nil - } - data, err := os.ReadFile(filePath) - if err != nil { - return "", false, err - } - - base := filepath.Base(filePath) - ext := filepath.Ext(base) - stem := strings.TrimSuffix(base, ext) - for n := 0; ; n++ { - name := base - if n > 0 { - name = fmt.Sprintf("%s-%d%s", stem, n, ext) - } - destPath := filepath.Join(configDir, name) - existing, err := os.ReadFile(destPath) - switch { - case os.IsNotExist(err): - if err := os.WriteFile(destPath, data, 0644); err != nil { - return "", false, err - } - return destPath, true, nil - case err != nil: - return "", false, err - case bytes.Equal(existing, data): - return destPath, false, nil - } - } -} - -// dropCopiedProfile removes the copy this command just made, so a rejected apply -// does not leave a profile in the config directory that was never applied (a -// file that was already there is left alone). -func dropCopiedProfile(path string, copied bool) { - if !copied { - return - } - if err := os.Remove(path); err != nil && !os.IsNotExist(err) { - log.Errorf("Failed to remove %s: %v", path, err) - } -} - func runICCRemove(cmd *cobra.Command, args []string) { outputName := args[0] diff --git a/core/internal/server/wayland/iccconfig.go b/core/internal/server/wayland/iccconfig.go new file mode 100644 index 000000000..3f1dc133b --- /dev/null +++ b/core/internal/server/wayland/iccconfig.go @@ -0,0 +1,181 @@ +package wayland + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + + "github.com/AvengeMedia/DankMaterialShell/core/internal/log" + "github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot" + "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" +) + +type iccConfig struct { + ICCProfiles map[string]string `json:"iccProfiles,omitempty"` + OutputTemps map[string]int `json:"outputTemps,omitempty"` +} + +func dmsConfigDir() string { + return filepath.Join(utils.XDGConfigHome(), "DankMaterialShell") +} + +func iccConfigPath() string { + return filepath.Join(dmsConfigDir(), "icc.json") +} + +func ICCProfilesDir() string { + return filepath.Join(dmsConfigDir(), "icc") +} + +func resolveICCProfile(name string) string { + if filepath.IsAbs(name) { + return name + } + return filepath.Join(ICCProfilesDir(), name) +} + +// Only the ICC fields persist, the shell pushes the night light state. +func LoadConfig() Config { + migrateLegacyICCConfig() + + cfg := DefaultConfig() + data, err := os.ReadFile(iccConfigPath()) + if err != nil { + return cfg + } + var stored iccConfig + if err := json.Unmarshal(data, &stored); err != nil { + log.Warnf("icc: ignoring %s: %v", iccConfigPath(), err) + return cfg + } + cfg.ICCProfiles = stored.ICCProfiles + cfg.OutputTemps = stored.OutputTemps + return cfg +} + +func SaveICCConfig(iccProfiles map[string]string, outputTemps map[string]int) error { + path := iccConfigPath() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + data, err := json.MarshalIndent(iccConfig{ICCProfiles: iccProfiles, OutputTemps: outputTemps}, "", " ") + if err != nil { + return err + } + return os.WriteFile(path, data, 0o644) +} + +func stageICCProfile(dir, src string) (string, error) { + if filepath.Dir(filepath.Clean(src)) == filepath.Clean(dir) { + return filepath.Base(src), nil + } + data, err := os.ReadFile(src) + if err != nil { + return "", err + } + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + + base := filepath.Base(src) + ext := filepath.Ext(base) + stem := strings.TrimSuffix(base, ext) + for n := 0; ; n++ { + name := base + if n > 0 { + name = fmt.Sprintf("%s-%d%s", stem, n, ext) + } + dest := filepath.Join(dir, name) + existing, err := os.ReadFile(dest) + switch { + case errors.Is(err, fs.ErrNotExist): + if err := os.WriteFile(dest, data, 0o644); err != nil { + return "", err + } + return name, nil + case err != nil: + return "", err + case bytes.Equal(existing, data): + return name, nil + } + } +} + +// !TODO: drop once master users have moved off /dms/wayland.json +func migrateLegacyICCConfig() { + if _, err := os.Stat(iccConfigPath()); !errors.Is(err, fs.ErrNotExist) { + return + } + legacyDir := legacyICCDir() + legacyPath := filepath.Join(legacyDir, "wayland.json") + data, err := os.ReadFile(legacyPath) + if err != nil { + return + } + var cfg iccConfig + if err := json.Unmarshal(data, &cfg); err != nil { + log.Warnf("icc: cannot migrate %s: %v", legacyPath, err) + return + } + + profilesDir := ICCProfilesDir() + legacyProfilesDir := filepath.Join(legacyDir, "icc") + moved := make(map[string]string) + entries, _ := os.ReadDir(legacyProfilesDir) + for _, entry := range entries { + if entry.IsDir() { + continue + } + src := filepath.Join(legacyProfilesDir, entry.Name()) + name, err := stageICCProfile(profilesDir, src) + if err != nil { + log.Warnf("icc: cannot move %s: %v", src, err) + continue + } + moved[src] = name + } + + for output, path := range cfg.ICCProfiles { + if name, ok := moved[filepath.Clean(path)]; ok { + cfg.ICCProfiles[output] = name + continue + } + name, err := stageICCProfile(profilesDir, path) + if err != nil { + log.Warnf("icc: keeping %s for %s: %v", path, output, err) + continue + } + cfg.ICCProfiles[output] = name + } + + if err := SaveICCConfig(cfg.ICCProfiles, cfg.OutputTemps); err != nil { + log.Warnf("icc: cannot migrate %s: %v", legacyPath, err) + return + } + if err := os.Remove(legacyPath); err != nil { + log.Warnf("icc: failed to remove %s: %v", legacyPath, err) + } + for src := range moved { + if err := os.Remove(src); err != nil { + log.Warnf("icc: failed to remove %s: %v", src, err) + } + } + _ = os.Remove(legacyProfilesDir) + log.Infof("icc: moved %s to %s", legacyPath, iccConfigPath()) +} + +func legacyICCDir() string { + compositorDir := "niri" + switch screenshot.DetectCompositor() { + case screenshot.CompositorHyprland: + compositorDir = "hypr" + case screenshot.CompositorMango: + compositorDir = "mango" + } + return filepath.Join(utils.XDGConfigHome(), compositorDir, "dms") +} diff --git a/core/cmd/dms/commands_icc_test.go b/core/internal/server/wayland/iccconfig_test.go similarity index 51% rename from core/cmd/dms/commands_icc_test.go rename to core/internal/server/wayland/iccconfig_test.go index 7314556f5..f0ad1d2cf 100644 --- a/core/cmd/dms/commands_icc_test.go +++ b/core/internal/server/wayland/iccconfig_test.go @@ -1,4 +1,4 @@ -package main +package wayland import ( "os" @@ -6,7 +6,7 @@ import ( "testing" ) -func TestStageProfile(t *testing.T) { +func TestStageICCProfile(t *testing.T) { configDir := t.TempDir() srcDir := t.TempDir() write := func(t *testing.T, path string, content string) { @@ -32,45 +32,45 @@ func TestStageProfile(t *testing.T) { write(t, first, "profile for DP-1") write(t, second, "profile for DP-2") - dest1, copied, err := stageProfile(configDir, first) + name1, err := stageICCProfile(configDir, first) if err != nil { - t.Fatalf("stageProfile(first): %v", err) + t.Fatalf("stageICCProfile(first): %v", err) } - if !copied || dest1 != filepath.Join(configDir, "display.icc") { - t.Fatalf("first copy: got %q copied=%v", dest1, copied) + if name1 != "display.icc" { + t.Fatalf("first copy: got %q", name1) } - dest2, copied, err := stageProfile(configDir, second) + name2, err := stageICCProfile(configDir, second) if err != nil { - t.Fatalf("stageProfile(second): %v", err) + t.Fatalf("stageICCProfile(second): %v", err) } - if !copied || dest2 != filepath.Join(configDir, "display-1.icc") { - t.Fatalf("same-named different profile must get a sibling: got %q copied=%v", dest2, copied) + if name2 != "display-1.icc" { + t.Fatalf("same-named different profile must get a sibling: got %q", name2) } - if got := read(t, dest1); got != "profile for DP-1" { + if got := read(t, filepath.Join(configDir, name1)); got != "profile for DP-1" { t.Fatalf("first copy was overwritten: %q", got) } - if got := read(t, dest2); got != "profile for DP-2" { + if got := read(t, filepath.Join(configDir, name2)); got != "profile for DP-2" { t.Fatalf("second copy has wrong content: %q", got) } - again, copied, err := stageProfile(configDir, second) + again, err := stageICCProfile(configDir, second) if err != nil { - t.Fatalf("stageProfile(second again): %v", err) + t.Fatalf("stageICCProfile(second again): %v", err) } - if copied || again != dest2 { - t.Fatalf("an identical copy must be reused, not rewritten: got %q copied=%v", again, copied) + if again != name2 { + t.Fatalf("an identical copy must be reused: got %q", again) } - inPlace, copied, err := stageProfile(configDir, dest1) + inPlace, err := stageICCProfile(configDir, filepath.Join(configDir, name1)) if err != nil { - t.Fatalf("stageProfile(in place): %v", err) + t.Fatalf("stageICCProfile(in place): %v", err) } - if copied || inPlace != dest1 { - t.Fatalf("a file already in the profile directory is used as it is: got %q copied=%v", inPlace, copied) + if inPlace != name1 { + t.Fatalf("a file already in the profile directory is used as it is: got %q", inPlace) } - if _, _, err := stageProfile(configDir, filepath.Join(srcDir, "missing.icc")); err == nil { + if _, err := stageICCProfile(configDir, filepath.Join(srcDir, "missing.icc")); err == nil { t.Fatal("expected an error for a missing source") } } diff --git a/core/internal/server/wayland/manager.go b/core/internal/server/wayland/manager.go index 98c893126..46791c38b 100644 --- a/core/internal/server/wayland/manager.go +++ b/core/internal/server/wayland/manager.go @@ -1539,7 +1539,8 @@ func (m *Manager) applyConfiguredICCForOutput(out *outputState, outputName strin changed = true } - if iccPath, ok := iccProfiles[outputName]; ok { + if profileName, ok := iccProfiles[outputName]; ok { + iccPath := resolveICCProfile(profileName) profile, err := icc.ParseFile(iccPath) switch { case err != nil: @@ -1559,7 +1560,7 @@ func (m *Manager) applyConfiguredICCForOutput(out *outputState, outputName strin } // validateOutputName rejects names no wl_output can report, so a typo or an -// empty argument cannot persist an entry in wayland.json that nothing will ever +// empty argument cannot persist an entry in icc.json that nothing will ever // match. func validateOutputName(name string) error { switch { @@ -1571,9 +1572,7 @@ func validateOutputName(name string) error { return nil } -// validateICCProfilePath rejects paths that cannot be resolved later: the path -// is persisted and read again on the next start, when the daemon no longer has -// the working directory the caller had. +// validateICCProfilePath rejects relative paths, the daemon does not share the caller's working directory. func validateICCProfilePath(path string) error { switch { case path == "": @@ -1584,9 +1583,8 @@ func validateICCProfilePath(path string) error { return nil } -// ApplyICC loads and applies an ICC profile to a specific output. +// ApplyICC copies an ICC profile into ICCProfilesDir and applies it to an output. // outputName is the wl_output name (e.g., "DP-1"). -// iccPath is the path to the .icm/.icc profile file. func (m *Manager) ApplyICC(outputName, iccPath string) error { if err := validateOutputName(outputName); err != nil { return err @@ -1595,7 +1593,6 @@ func (m *Manager) ApplyICC(outputName, iccPath string) error { return err } - // 1. Parse and validate the ICC profile profile, err := icc.ParseFile(iccPath) if err != nil { return fmt.Errorf("icc: failed to parse %s: %w", iccPath, err) @@ -1610,7 +1607,13 @@ func (m *Manager) ApplyICC(outputName, iccPath string) error { return fmt.Errorf("icc: %s has no vcgt table, so it has no gamma ramp to apply", iccPath) } - // 2. Post to wayland actor thread to apply + profilesDir := ICCProfilesDir() + profileName, err := stageICCProfile(profilesDir, iccPath) + if err != nil { + return fmt.Errorf("icc: failed to store %s in %s: %w", iccPath, profilesDir, err) + } + stagedPath := filepath.Join(profilesDir, profileName) + m.post(func() { defer m.updateStateFromSchedule() @@ -1620,12 +1623,11 @@ func (m *Manager) ApplyICC(outputName, iccPath string) error { return } - // Find the output by name var targetOutput *outputState m.outputs.Range(func(_ uint32, out *outputState) bool { if name, ok := m.outputNames.Load(out.id); ok && name == outputName { targetOutput = out - return false // stop + return false } return true }) @@ -1634,7 +1636,7 @@ func (m *Manager) ApplyICC(outputName, iccPath string) error { return } - targetOutput.iccPath = iccPath + targetOutput.iccPath = stagedPath targetOutput.iccProfile = profile m.publishICCState() @@ -1644,17 +1646,14 @@ func (m *Manager) ApplyICC(outputName, iccPath string) error { m.applyCurrentTemp("icc-apply") }) - // 3. Persist to config m.configMutex.Lock() if m.config.ICCProfiles == nil { m.config.ICCProfiles = make(map[string]string) } - m.config.ICCProfiles[outputName] = iccPath + m.config.ICCProfiles[outputName] = profileName savedConfig := cloneConfig(m.config) m.configMutex.Unlock() - // Save to disk. Only the ICC fields are written: the night light fields in - // this config belong to the shell, not to the daemon. if err := SaveICCConfig(savedConfig.ICCProfiles, savedConfig.OutputTemps); err != nil { log.Warnf("icc: failed to save config: %v", err) } diff --git a/core/internal/server/wayland/manager_test.go b/core/internal/server/wayland/manager_test.go index f02a15ca5..6a4ac6e0f 100644 --- a/core/internal/server/wayland/manager_test.go +++ b/core/internal/server/wayland/manager_test.go @@ -625,9 +625,9 @@ func TestManager_ConfigSnapshotConcurrentMarshal(t *testing.T) { } // On a default install (night light off, no profile) no gamma control exists, -// so a per-output temperature used to be written to wayland.json without ever -// being applied or reported: the settings slider snapped back to "Default" and -// the value only took effect after a daemon restart. +// so a per-output temperature used to be persisted without ever being applied +// or reported: the settings slider snapped back to "Default" and the value only +// took effect after a daemon restart. func TestManager_SetOutputTempWithoutControlsPublishesValue(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", t.TempDir()) @@ -654,15 +654,12 @@ func TestManager_SetOutputTempWithoutControlsPublishesValue(t *testing.T) { assert.Equal(t, 7000, m.GetOutputTemps()["DP-1"], "the value is reported even without gamma controls") - path, err := getConfigPath() - if err != nil { - t.Fatalf("config path: %v", err) - } + path := iccConfigPath() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read %s: %v", path, err) } - var saved Config + var saved iccConfig if err := json.Unmarshal(data, &saved); err != nil { t.Fatalf("unmarshal %s: %v", path, err) } @@ -670,9 +667,9 @@ func TestManager_SetOutputTempWithoutControlsPublishesValue(t *testing.T) { } // Arguments that no wl_output or file can satisfy are rejected before anything is -// written: an empty name would persist an entry in wayland.json that nothing can -// match, and a relative profile path cannot be resolved by the next start, when -// the daemon no longer has the caller's working directory. +// written: an empty name would persist an entry in icc.json that nothing can +// match, and a relative profile path cannot be resolved by the daemon, which +// does not have the caller's working directory. func TestManager_RejectsInvalidICCArguments(t *testing.T) { m := &Manager{config: DefaultConfig()} diff --git a/core/internal/server/wayland/types.go b/core/internal/server/wayland/types.go index efaa841e9..3119fa03f 100644 --- a/core/internal/server/wayland/types.go +++ b/core/internal/server/wayland/types.go @@ -1,11 +1,7 @@ package wayland import ( - "encoding/json" - "fmt" "math" - "os" - "path/filepath" "slices" "sync" "sync/atomic" @@ -14,8 +10,6 @@ import ( "github.com/AvengeMedia/DankMaterialShell/core/internal/errdefs" "github.com/AvengeMedia/DankMaterialShell/core/internal/geolocation" "github.com/AvengeMedia/DankMaterialShell/core/internal/icc" - "github.com/AvengeMedia/DankMaterialShell/core/internal/screenshot" - "github.com/AvengeMedia/DankMaterialShell/core/internal/utils" "github.com/AvengeMedia/dankgo/syncmap" wlclient "github.com/AvengeMedia/dankgo/wayland/client" "github.com/godbus/dbus/v5" @@ -44,7 +38,7 @@ type Config struct { Enabled bool ElevationTwilight float64 ElevationDaylight float64 - ICCProfiles map[string]string `json:"iccProfiles,omitempty"` // outputName -> ICC file path + ICCProfiles map[string]string `json:"iccProfiles,omitempty"` // outputName -> file in ICCProfilesDir, or an absolute path OutputTemps map[string]int `json:"outputTemps,omitempty"` // outputName -> per-output color temperature (K) } @@ -183,117 +177,6 @@ func DefaultConfig() Config { } } -// DMSConfigDir returns the compositor-specific DMS config directory, matching -// the layout used by the shell and `dms setup` (niri/dms, hypr/dms, mango/dms). -func DMSConfigDir() string { - return filepath.Join(utils.XDGConfigHome(), compositorConfigDirName(), "dms") -} - -// ICCProfilesDir returns the directory scanned for ICC profile files. -func ICCProfilesDir() string { - return filepath.Join(DMSConfigDir(), "icc") -} - -// compositorConfigDirName maps the running compositor to its DMS config -// directory name. Compositors without a DMS config layout fall back to niri. -func compositorConfigDirName() string { - switch screenshot.DetectCompositor() { - case screenshot.CompositorHyprland: - return "hypr" - case screenshot.CompositorMango: - return "mango" - default: - return "niri" - } -} - -// getConfigPath returns the path to the wayland config file. -func getConfigPath() (string, error) { - dir := DMSConfigDir() - if !filepath.IsAbs(dir) { - return "", fmt.Errorf("could not determine DMS config directory") - } - return filepath.Join(dir, "wayland.json"), nil -} - -// LoadConfig reads the wayland config from disk, falling back to DefaultConfig. -func LoadConfig() Config { - path, err := getConfigPath() - if err != nil { - return DefaultConfig() - } - data, err := os.ReadFile(path) - if err != nil { - return DefaultConfig() - } - var cfg Config - if err := json.Unmarshal(data, &cfg); err != nil { - return DefaultConfig() - } - // Ensure defaults are set for missing fields (keep in sync with DefaultConfig) - def := DefaultConfig() - if cfg.HighTemp == 0 { - cfg.HighTemp = def.HighTemp - } - if cfg.LowTemp == 0 { - cfg.LowTemp = def.LowTemp - } - if cfg.Gamma == 0 { - cfg.Gamma = def.Gamma - } - if cfg.Contrast == 0 { - cfg.Contrast = def.Contrast - } - if cfg.ElevationTwilight == 0 { - cfg.ElevationTwilight = def.ElevationTwilight - } - if cfg.ElevationDaylight == 0 { - cfg.ElevationDaylight = def.ElevationDaylight - } - if cfg.Outputs == nil { - cfg.Outputs = []string{} - } - if cfg.ICCProfiles == nil { - cfg.ICCProfiles = make(map[string]string) - } - if cfg.OutputTemps == nil { - cfg.OutputTemps = make(map[string]int) - } - return cfg -} - -// SaveConfig writes the wayland config to disk. -func SaveConfig(cfg Config) error { - path, err := getConfigPath() - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - data, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - return err - } - return os.WriteFile(path, data, 0o644) -} - -// SaveICCConfig stores the ICC profiles and the per-output temperatures, leaving -// every other field of the file as it is on disk. -// -// The night light fields are owned by the shell: the daemon only reads them at -// boot and writes them in memory when the shell calls setEnabled/setTemperature, -// so writing its copy back would persist whatever the night light happened to be -// doing when an ICC change was made (for example `"Enabled": true` while the -// user had since turned it off), and the shell only sends setEnabled when the -// mode is on, so the state would silently come back after a session restart. -func SaveICCConfig(iccProfiles map[string]string, outputTemps map[string]int) error { - cfg := LoadConfig() - cfg.ICCProfiles = iccProfiles - cfg.OutputTemps = outputTemps - return SaveConfig(cfg) -} - func (c *Config) Validate() error { if c.LowTemp < 1000 || c.LowTemp > 10000 { return errdefs.ErrInvalidTemperature diff --git a/core/internal/server/wayland/types_test.go b/core/internal/server/wayland/types_test.go index fffb3bf02..490d9e038 100644 --- a/core/internal/server/wayland/types_test.go +++ b/core/internal/server/wayland/types_test.go @@ -3,8 +3,6 @@ package wayland import ( "testing" "time" - - "github.com/stretchr/testify/assert" ) func TestConfigValidate(t *testing.T) { @@ -273,36 +271,3 @@ func TestStateChanged(t *testing.T) { }) } } - -// The night light fields in wayland.json belong to the shell: the daemon reads -// them at boot and the shell pushes changes through IPC, so an ICC change must -// not write the daemon's copy of them back to disk. Otherwise applying a profile -// while the night light happens to be on persists "Enabled": true, and the mode -// comes back after a session restart with no way to resync from the UI. -func TestSaveICCConfigKeepsNightLightFields(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", t.TempDir()) - - onDisk := Config{ - Enabled: true, - LowTemp: 3000, - HighTemp: 6000, - Gamma: 1.1, - Contrast: 1.0, - Outputs: []string{}, - } - if err := SaveConfig(onDisk); err != nil { - t.Fatalf("SaveConfig: %v", err) - } - - if err := SaveICCConfig(map[string]string{"DP-1": "/tmp/dp1.icc"}, map[string]int{"DP-1": 7000}); err != nil { - t.Fatalf("SaveICCConfig: %v", err) - } - - got := LoadConfig() - assert.True(t, got.Enabled, "the night light state on disk must survive an ICC write") - assert.Equal(t, 3000, got.LowTemp) - assert.Equal(t, 6000, got.HighTemp) - assert.Equal(t, 1.1, got.Gamma) - assert.Equal(t, map[string]string{"DP-1": "/tmp/dp1.icc"}, got.ICCProfiles) - assert.Equal(t, map[string]int{"DP-1": 7000}, got.OutputTemps) -}