mirror of
https://github.com/AvengeMedia/DankMaterialShell.git
synced 2026-10-18 09:14:01 -04:00
icc: keep profiles and assignments in the dms config dir
This commit is contained in:
@@ -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]
|
||||
|
||||
|
||||
@@ -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 <compositor>/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")
|
||||
}
|
||||
+21
-21
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user