diff --git a/core/internal/utils/gsettings.go b/core/internal/utils/gsettings.go index cc8d08433..51677fa6e 100644 --- a/core/internal/utils/gsettings.go +++ b/core/internal/utils/gsettings.go @@ -2,6 +2,7 @@ package utils import ( "fmt" + "os" "os/exec" "strings" ) @@ -10,22 +11,35 @@ func dconfPath(schema, key string) string { return "/" + strings.ReplaceAll(schema, ".", "/") + "/" + key } -// GsettingsGet reads a gsettings value, falling back to dconf read. +// gsettings reaches whichever backend its own GLib links, not necessarily the +// dconf store GTK apps read, and it exits 0 either way. +func dconfSelected() bool { + backend := os.Getenv("GSETTINGS_BACKEND") + return backend == "" || backend == "dconf" +} + +// GsettingsGet reads a desktop setting from dconf, falling back to gsettings. func GsettingsGet(schema, key string) (string, error) { - if out, err := exec.Command("gsettings", "get", schema, key).Output(); err == nil { - return strings.TrimSpace(string(out)), nil + if dconfSelected() { + if out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output(); err == nil { + if value := strings.TrimSpace(string(out)); value != "" { + return value, nil + } + } } - out, err := exec.Command("dconf", "read", dconfPath(schema, key)).Output() + out, err := exec.Command("gsettings", "get", schema, key).Output() if err != nil { - return "", fmt.Errorf("gsettings/dconf get failed for %s %s: %w", schema, key, err) + return "", fmt.Errorf("dconf/gsettings get failed for %s %s: %w", schema, key, err) } return strings.TrimSpace(string(out)), nil } -// GsettingsSet writes a gsettings value, falling back to dconf write. +// GsettingsSet writes a desktop setting to dconf, falling back to gsettings. func GsettingsSet(schema, key, value string) error { - if err := exec.Command("gsettings", "set", schema, key, value).Run(); err == nil { - return nil + if dconfSelected() { + if err := exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run(); err == nil { + return nil + } } - return exec.Command("dconf", "write", dconfPath(schema, key), "'"+value+"'").Run() + return exec.Command("gsettings", "set", schema, key, value).Run() } diff --git a/core/internal/utils/gsettings_test.go b/core/internal/utils/gsettings_test.go new file mode 100644 index 000000000..1832bbf93 --- /dev/null +++ b/core/internal/utils/gsettings_test.go @@ -0,0 +1,117 @@ +package utils + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeTool struct { + name string + stdout string + exit int +} + +func fakeTools(t *testing.T, tools ...fakeTool) string { + t.Helper() + binDir := t.TempDir() + logPath := filepath.Join(binDir, "calls.log") + for _, tool := range tools { + script := fmt.Sprintf("#!/bin/sh\necho \"%s $*\" >> %q\nprintf '%%s' %q\nexit %d\n", tool.name, logPath, tool.stdout, tool.exit) + require.NoError(t, os.WriteFile(filepath.Join(binDir, tool.name), []byte(script), 0o755)) + } + t.Setenv("PATH", binDir) + return logPath +} + +func toolCalls(t *testing.T, logPath string) string { + t.Helper() + data, err := os.ReadFile(logPath) + if os.IsNotExist(err) { + return "" + } + require.NoError(t, err) + return string(data) +} + +func TestGsettingsSetWritesDconf(t *testing.T) { + t.Setenv("GSETTINGS_BACKEND", "") + logPath := fakeTools(t, + fakeTool{name: "dconf"}, + fakeTool{name: "gsettings"}, + ) + + require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3")) + + calls := toolCalls(t, logPath) + assert.Contains(t, calls, "dconf write /org/gnome/desktop/interface/gtk-theme 'adw-gtk3'") + assert.NotContains(t, calls, "gsettings") +} + +func TestGsettingsSetFallsBackWhenDconfFails(t *testing.T) { + t.Setenv("GSETTINGS_BACKEND", "") + logPath := fakeTools(t, + fakeTool{name: "dconf", exit: 1}, + fakeTool{name: "gsettings"}, + ) + + require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3")) + + assert.Contains(t, toolCalls(t, logPath), "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3") +} + +func TestGsettingsSetHonorsBackendOverride(t *testing.T) { + t.Setenv("GSETTINGS_BACKEND", "keyfile") + logPath := fakeTools(t, + fakeTool{name: "dconf"}, + fakeTool{name: "gsettings"}, + ) + + require.NoError(t, GsettingsSet("org.gnome.desktop.interface", "gtk-theme", "adw-gtk3")) + + calls := toolCalls(t, logPath) + assert.Contains(t, calls, "gsettings set org.gnome.desktop.interface gtk-theme adw-gtk3") + assert.NotContains(t, calls, "dconf write") +} + +func TestGsettingsGetReadsDconf(t *testing.T) { + t.Setenv("GSETTINGS_BACKEND", "") + fakeTools(t, + fakeTool{name: "dconf", stdout: "'adw-gtk3-dark'"}, + fakeTool{name: "gsettings", stdout: "'Adwaita'"}, + ) + + value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme") + + require.NoError(t, err) + assert.Equal(t, "'adw-gtk3-dark'", value) +} + +func TestGsettingsGetFallsBackWhenDconfUnset(t *testing.T) { + t.Setenv("GSETTINGS_BACKEND", "") + fakeTools(t, + fakeTool{name: "dconf"}, + fakeTool{name: "gsettings", stdout: "'Adwaita'"}, + ) + + value, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme") + + require.NoError(t, err) + assert.Equal(t, "'Adwaita'", value) +} + +func TestGsettingsGetFailsWhenNoToolWorks(t *testing.T) { + t.Setenv("GSETTINGS_BACKEND", "") + fakeTools(t, + fakeTool{name: "dconf", exit: 1}, + fakeTool{name: "gsettings", exit: 1}, + ) + + _, err := GsettingsGet("org.gnome.desktop.interface", "gtk-theme") + + require.ErrorContains(t, err, "gtk-theme") +} diff --git a/quickshell/Common/GSettings.js b/quickshell/Common/GSettings.js new file mode 100644 index 000000000..ee12ec4bc --- /dev/null +++ b/quickshell/Common/GSettings.js @@ -0,0 +1,19 @@ +.pragma library + +function path(schema, key) { + return "/" + schema.replace(/\./g, "/") + "/" + key; +} + +// gsettings reaches whichever backend its own GLib links, not necessarily the +// dconf store GTK apps read, and it exits 0 either way. +function backendIsDconf() { + return "[ \"${GSETTINGS_BACKEND:-dconf}\" = dconf ]"; +} + +function getCmd(schema, key) { + return "{ { " + backendIsDconf() + " && dconf read " + path(schema, key) + " 2>/dev/null | grep .; } || gsettings get " + schema + " " + key + " 2>/dev/null; } | tr -d \"'\""; +} + +function setCmd(schema, key, value) { + return "{ " + backendIsDconf() + " && dconf write " + path(schema, key) + " \"'" + value + "'\" 2>/dev/null; } || gsettings set " + schema + " " + key + " '" + value + "'"; +} diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index 270319d13..d072e3d81 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -8,6 +8,7 @@ import Quickshell.Io import qs.Common import qs.Common.settings import qs.Services +import "GSettings.js" as GSettings import "settings/SettingsSpec.js" as Spec import "settings/SettingsStore.js" as Store @@ -1524,11 +1525,7 @@ Singleton { return; if (!SessionData.lastAppliedIconTheme) return; - const script = `if command -v gsettings >/dev/null 2>&1; then - gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" - elif command -v dconf >/dev/null 2>&1; then - dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g" - fi`; + const script = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme"); Proc.runCommand("iconThemeDriftCheck", ["sh", "-c", script], (output, exitCode) => { const platform = (output || "").trim(); @@ -1566,11 +1563,7 @@ Singleton { const resolved = resolveIconTheme(); let cosmicThemeName = (resolved === "System Default") ? systemDefaultIconTheme : resolved; if (!cosmicThemeName || cosmicThemeName === "System Default") { - const detectScript = `if command -v gsettings >/dev/null 2>&1; then - gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" - elif command -v dconf >/dev/null 2>&1; then - dconf read /org/gnome/desktop/interface/icon-theme 2>/dev/null | sed "s/'//g" - fi`; + const detectScript = GSettings.getCmd("org.gnome.desktop.interface", "icon-theme"); Proc.runCommand("detectCosmicIconTheme", ["sh", "-c", detectScript], (output, exitCode) => { if (exitCode !== 0) @@ -1632,11 +1625,7 @@ Singleton { fi done - if command -v gsettings >/dev/null 2>&1; then - gsettings set org.gnome.desktop.interface icon-theme '${gtkThemeName}' 2>/dev/null || true - elif command -v dconf >/dev/null 2>&1; then - dconf write /org/gnome/desktop/interface/icon-theme "'${gtkThemeName}'" 2>/dev/null || true - fi + ${GSettings.setCmd("org.gnome.desktop.interface", "icon-theme", gtkThemeName)} || true pkill -HUP -f 'gtk' 2>/dev/null || true`; @@ -2132,7 +2121,7 @@ Singleton { const pathsArg = iconPaths.join(" "); const script = ` - echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null | sed "s/'//g" || echo '')" + echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "icon-theme")})" for dir in ${pathsArg}; do [ -d "$dir" ] || continue for theme in "$dir"/*/; do @@ -2171,7 +2160,7 @@ Singleton { const pathsArg = cursorPaths.join(" "); const script = ` - echo "SYSDEFAULT:$(gsettings get org.gnome.desktop.interface cursor-theme 2>/dev/null | sed "s/'//g" || echo '')" + echo "SYSDEFAULT:$(${GSettings.getCmd("org.gnome.desktop.interface", "cursor-theme")})" for dir in ${pathsArg}; do [ -d "$dir" ] || continue for theme in "$dir"/*/; do diff --git a/quickshell/Common/Theme.qml b/quickshell/Common/Theme.qml index bf7c67b86..bf7c9860d 100644 --- a/quickshell/Common/Theme.qml +++ b/quickshell/Common/Theme.qml @@ -10,6 +10,7 @@ import qs.DankCommon.Common as DankCommon import qs.Services import qs.Modules.Greetd import "StockThemes.js" as StockThemes +import "GSettings.js" as GSettings Singleton { id: root @@ -1918,23 +1919,12 @@ Singleton { const theme = isLight ? "adw-gtk3" : "adw-gtk3-dark"; const schema = "org.gnome.desktop.interface"; const key = "gtk-theme"; + const reset = GSettings.setCmd(schema, key, ""); + const apply = GSettings.setCmd(schema, key, theme); - const makeCmd = (tool, schema, val) => { - if (tool === "gsettings") { - return `gsettings set ${schema} ${key} '' && gsettings set ${schema} ${key} ${val}`; - } else { - const dconfPath = `/${schema.replace(/\./g, "/")}`; - return `dconf write ${dconfPath}/${key} "''" && dconf write ${dconfPath}/${key} "'${val}'"`; - } - }; - - Proc.runCommand("gtkRefresher", ["sh", "-c", makeCmd("gsettings", schema, theme)], (output, exitCode) => { + Proc.runCommand("gtkRefresher", ["sh", "-c", `${reset}; ${apply}`], (output, exitCode) => { if (exitCode !== 0) { - Proc.runCommand("gtkRefreshFallback", ["sh", "-c", makeCmd("dconf", schema, theme)], (output, exitCode) => { - if (exitCode !== 0) { - log.warn("Failed to refresh gtk-theme"); - } - }); + log.warn("Failed to refresh gtk-theme"); } }); } diff --git a/quickshell/Modules/Settings/SoundsTab.qml b/quickshell/Modules/Settings/SoundsTab.qml index a9382a886..93e284a71 100644 --- a/quickshell/Modules/Settings/SoundsTab.qml +++ b/quickshell/Modules/Settings/SoundsTab.qml @@ -91,7 +91,7 @@ Item { tab: "sounds" tags: ["sound", "theme", "system"] settingKey: "useSystemSoundTheme" - visible: AudioService.gsettingsAvailable + visible: AudioService.soundThemeSupported text: I18n.tr("Use System Theme") description: I18n.tr("Use sound theme from system settings") checked: SettingsData.useSystemSoundTheme @@ -123,7 +123,7 @@ Item { height: 1 color: Theme.outline opacity: 0.2 - visible: AudioService.gsettingsAvailable + visible: AudioService.soundThemeSupported } SettingsToggleRow { diff --git a/quickshell/Services/AudioService.qml b/quickshell/Services/AudioService.qml index 101d4fe76..6e618ade6 100644 --- a/quickshell/Services/AudioService.qml +++ b/quickshell/Services/AudioService.qml @@ -8,6 +8,7 @@ import Quickshell.Io import Quickshell.Services.Pipewire import qs.Common import qs.Services +import "../Common/GSettings.js" as GSettings Singleton { id: root @@ -18,7 +19,7 @@ Singleton { readonly property bool soundsAvailable: MultimediaService.available property bool playersRequested: false - property bool gsettingsAvailable: false + property bool soundThemeSupported: false property var availableSoundThemes: [] property string currentSoundTheme: "" property var soundFilePaths: ({}) @@ -545,10 +546,10 @@ EOFCONFIG } } - function checkGsettings() { - Proc.runCommand("checkGsettings", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null"], (output, exitCode) => { - gsettingsAvailable = (exitCode === 0); - if (gsettingsAvailable) { + function checkSoundThemeSupport() { + Proc.runCommand("checkSoundThemeSupport", ["sh", "-c", GSettings.getCmd("org.gnome.desktop.sound", "theme-name")], (output, exitCode) => { + soundThemeSupported = (output || "").trim().length > 0; + if (soundThemeSupported) { scanSoundThemes(); getCurrentSoundTheme(); } @@ -581,8 +582,8 @@ EOFCONFIG } function getCurrentSoundTheme() { - Proc.runCommand("getCurrentSoundTheme", ["sh", "-c", "gsettings get org.gnome.desktop.sound theme-name 2>/dev/null | sed \"s/'//g\""], (output, exitCode) => { - if (exitCode === 0 && output.trim()) { + Proc.runCommand("getCurrentSoundTheme", ["sh", "-c", GSettings.getCmd("org.gnome.desktop.sound", "theme-name")], (output, exitCode) => { + if (output.trim()) { currentSoundTheme = output.trim(); log.debug("Current system sound theme:", currentSoundTheme); if (SettingsData.useSystemSoundTheme) { @@ -600,7 +601,7 @@ EOFCONFIG return; } - Proc.runCommand("setSoundTheme", ["sh", "-c", `gsettings set org.gnome.desktop.sound theme-name '${themeName}'`], (output, exitCode) => { + Proc.runCommand("setSoundTheme", ["sh", "-c", GSettings.setCmd("org.gnome.desktop.sound", "theme-name", themeName)], (output, exitCode) => { if (exitCode === 0) { currentSoundTheme = themeName; if (SettingsData.useSystemSoundTheme) { @@ -1171,7 +1172,7 @@ EOFCONFIG onSoundsAvailableChanged: { if (!soundsAvailable) return; - checkGsettings(); + checkSoundThemeSupport(); } Component.onCompleted: { diff --git a/quickshell/Services/IconThemeService.qml b/quickshell/Services/IconThemeService.qml index 604f9fd80..c8757a877 100644 --- a/quickshell/Services/IconThemeService.qml +++ b/quickshell/Services/IconThemeService.qml @@ -84,7 +84,7 @@ Singleton { if (Quickshell.env("QS_ICON_THEME")) return; const script = `v=$(sed -n 's/^gtk-icon-theme-name *= *//p' "\${XDG_CONFIG_HOME:-$HOME/.config}/gtk-3.0/settings.ini" 2>/dev/null | head -1) -[ -z "$v" ] && command -v gsettings >/dev/null 2>&1 && v=$(gsettings get org.gnome.desktop.interface icon-theme 2>/dev/null) +[ -z "$v" ] && v=$(${GSettings.getCmd("org.gnome.desktop.interface", "icon-theme")}) printf '%s' "$v" | tr -d "'\\""`; Proc.runCommand("iconThemeProbe", ["sh", "-c", script], (out, code) => { diff --git a/quickshell/Services/PortalService.qml b/quickshell/Services/PortalService.qml index c0f7876de..b91b727c6 100644 --- a/quickshell/Services/PortalService.qml +++ b/quickshell/Services/PortalService.qml @@ -6,6 +6,7 @@ import Quickshell import Quickshell.Io import qs.Common import qs.Services +import "../Common/GSettings.js" as GSettings Singleton { id: root @@ -19,7 +20,6 @@ Singleton { property bool colorSchemeInitialized: false property bool freedeskAvailable: false - property string colorSchemeCommand: "" property string pendingProfileImage: "" readonly property string socketPath: Quickshell.env("DMS_SOCKET") @@ -146,14 +146,7 @@ Singleton { const preferLight = isLightMode && systemColorScheme === 2; const targetScheme = isLightMode ? (preferLight ? "prefer-light" : "default") : "prefer-dark"; - switch (colorSchemeCommand) { - case "gsettings": - Quickshell.execDetached(["gsettings", "set", "org.gnome.desktop.interface", "color-scheme", targetScheme]); - break; - case "dconf": - Quickshell.execDetached(["dconf", "write", "/org/gnome/desktop/interface/color-scheme", `'${targetScheme}'`]); - break; - } + Quickshell.execDetached(["sh", "-c", GSettings.setCmd("org.gnome.desktop.interface", "color-scheme", targetScheme)]); } function setSystemIconTheme(themeName) { @@ -207,7 +200,6 @@ Singleton { } else { log.info("DMS_SOCKET not set"); } - colorSchemeDetector.running = true; } Connections { @@ -348,23 +340,6 @@ Singleton { } } - Process { - id: colorSchemeDetector - command: ["bash", "-c", "command -v gsettings || command -v dconf"] - running: false - - stdout: StdioCollector { - onStreamFinished: { - const cmd = text.trim(); - if (cmd.includes("gsettings")) { - root.colorSchemeCommand = "gsettings"; - } else if (cmd.includes("dconf")) { - root.colorSchemeCommand = "dconf"; - } - } - } - } - IpcHandler { target: "profile"