systemd: dms systemd command binds dms.service to the preferred compositor session unit

- niri, mango & hyprland supported
- `dms systemd enable/disable/status/start/stop/restart` supported
This commit is contained in:
purian23 committed 2026-10-17 03:47:06 -04:00
1 parent d8a87a8e13
commit ae361e37fc
11 files changed
+448 -64

No files matched your search

+1
View File
@@ -792,6 +792,7 @@ func getCommonCommands() []*cobra.Command {
blurCmd,
trashCmd,
systemCmd,
systemdCmd,
switchUserCmd,
dmenuCmd,
}...)
+15 -10
View File
@@ -886,11 +886,6 @@ func checkWindowManagers() []checkResult {
return results
}
func mangoWantsDMS() bool {
_, err := os.Stat(mangoconf.WantsLink())
return err == nil
}
var mangoLegacyOverviewBinds = regexp.MustCompile(`(?im)^\s*mousebind\s*=\s*none\s*,\s*(btn_left\s*,\s*toggleoverview\s*,\s*1|btn_right\s*,\s*killclient\s*,\s*0)\s*$`)
func checkMangoConfig(mangoDir string, dialect mangoconf.Dialect) []checkResult {
@@ -1454,6 +1449,7 @@ func checkSystemdServices() []checkResult {
var results []checkResult
dmsState := getServiceState("dms", true)
wanted := config.SessionWanted()
if !dmsState.exists {
results = append(results, checkResult{catServices, "dms.service", statusInfo, "Not installed", "Optional user service", doctorDocsURL + "#services"})
} else {
@@ -1465,8 +1461,8 @@ func checkSystemdServices() []checkResult {
case dmsState.active == "failed":
status = statusError
case dmsState.active == "active":
case dmsState.enabled == "disabled" && os.Getenv("MANGO_INSTANCE_SIGNATURE") != "" && mangoWantsDMS():
message = "Started with " + mangoconf.SessionTarget
case dmsState.enabled == "disabled" && len(wanted) > 0:
message = "Started with " + strings.Join(wanted, ", ")
if dmsState.active != "" {
message += ", " + dmsState.active
}
@@ -1477,7 +1473,16 @@ func checkSystemdServices() []checkResult {
case dmsState.active == "inactive":
status = statusError
}
results = append(results, checkResult{catServices, "dms.service", status, message, "", doctorDocsURL + "#services"})
details := ""
if len(wanted) > 0 {
details = "Wanted by " + strings.Join(wanted, ", ")
}
if wm, ok := config.RunningCompositor(); ok && status != statusOK {
if unit := config.SessionUnitName(wm); !slices.Contains(wanted, unit) {
details = fmt.Sprintf("Nothing starts dms.service with %s. Run 'dms systemd enable'.", unit)
}
}
results = append(results, checkResult{catServices, "dms.service", status, message, details, doctorDocsURL + "#services"})
}
if dmsState.exists && dmsState.enabled == "enabled" {
@@ -1607,7 +1612,7 @@ func printResultLine(r checkResult, styles tui.Styles) {
fmt.Printf(" %s %s %s %s\n", style.Render(icon), name, styles.Subtle.Render(dots), r.message)
if doctorVerbose && r.details != "" {
if r.details != "" && (doctorVerbose || r.status != statusOK) {
fmt.Printf(" %s\n", styles.Subtle.Render("└─ "+r.details))
}
@@ -1666,7 +1671,7 @@ func formatResultsPlain(results []checkResult) string {
fmt.Fprintf(&sb, "- [%s] %s: %s\n", r.status, r.name, r.message)
if doctorVerbose && r.details != "" {
if r.details != "" && (doctorVerbose || r.status != statusOK) {
fmt.Fprintf(&sb, " - %s\n", r.details)
}
}
+134
View File
@@ -0,0 +1,134 @@
package main
import (
"fmt"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/config"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/spf13/cobra"
)
var systemdCompositorFlag string
var systemdCmd = &cobra.Command{
Use: "systemd",
Short: "Start DMS as a systemd user service with the compositor session",
Long: `Manage dms.service for the compositor session.
enable makes the compositor's own session unit want dms.service, so DMS starts
with that compositor only: hyprland-session.target, niri.service or
mango-session.target. The compositor is the running one, or --compositor.`,
}
var systemdEnableCmd = &cobra.Command{
Use: "enable",
Short: "Start dms.service with the compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
wm, err := systemdCompositor()
if err != nil {
return err
}
unit, err := config.EnableSession(wm)
if err != nil {
return err
}
fmt.Printf("dms.service starts with %s\n", unit)
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
if err := runSystemctl("--user", "start", dmsSystemdUnit); err != nil {
return err
}
fmt.Println("dms.service started")
return nil
},
}
var systemdDisableCmd = &cobra.Command{
Use: "disable",
Short: "Stop starting dms.service with any compositor session",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
removed, err := config.DisableSession()
if err != nil {
return err
}
if len(removed) == 0 {
fmt.Println("dms.service was not wanted by any compositor session")
} else {
fmt.Printf("dms.service no longer starts with %s\n", strings.Join(removed, ", "))
}
now, _ := cmd.Flags().GetBool("now")
if !now {
return nil
}
return runSystemctl("--user", "stop", dmsSystemdUnit)
},
}
var systemdStatusCmd = &cobra.Command{
Use: "status",
Short: "Show which compositor sessions start dms.service",
Args: cobra.NoArgs,
Run: func(cmd *cobra.Command, args []string) {
state := getServiceState("dms", true)
if !state.exists {
fmt.Println("dms.service: not installed")
return
}
fmt.Printf("dms.service: %s", state.enabled)
if state.active != "" {
fmt.Printf(", %s", state.active)
}
fmt.Println()
wanted := config.SessionWanted()
if len(wanted) == 0 {
fmt.Println("Starts with: nothing (run 'dms systemd enable')")
return
}
fmt.Printf("Starts with: %s\n", strings.Join(wanted, ", "))
},
}
func systemdLifecycleCmd(verb string) *cobra.Command {
return &cobra.Command{
Use: verb,
Short: strings.ToUpper(verb[:1]) + verb[1:] + " dms.service",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return runSystemctl("--user", verb, dmsSystemdUnit)
},
}
}
func systemdCompositor() (deps.WindowManager, error) {
if systemdCompositorFlag == "" {
if wm, ok := config.RunningCompositor(); ok {
return wm, nil
}
name, err := detectCompositorForSetup()
if err != nil {
return 0, err
}
systemdCompositorFlag = name
}
switch strings.ToLower(systemdCompositorFlag) {
case "hyprland":
return deps.WindowManagerHyprland, nil
case "niri":
return deps.WindowManagerNiri, nil
case "mango", "mangowc":
return deps.WindowManagerMango, nil
}
return 0, fmt.Errorf("unknown compositor %q (hyprland, niri or mango)", systemdCompositorFlag)
}
func init() {
systemdEnableCmd.Flags().StringVar(&systemdCompositorFlag, "compositor", "", "hyprland, niri or mango (default: the running one)")
systemdEnableCmd.Flags().Bool("now", false, "Also start dms.service")
systemdDisableCmd.Flags().Bool("now", false, "Also stop dms.service")
systemdCmd.AddCommand(systemdEnableCmd, systemdDisableCmd, systemdStatusCmd, systemdLifecycleCmd("start"), systemdLifecycleCmd("stop"), systemdLifecycleCmd("restart"))
}
+2 -10
View File
@@ -262,7 +262,7 @@ func (cd *ConfigDeployer) deployNiriConfig(terminalCommand string, useSystemd bo
newConfig := strings.ReplaceAll(NiriConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !useSystemd {
if !cd.systemdAutostart(deps.WindowManagerNiri, useSystemd) {
newConfig = cd.transformNiriConfigForNonSystemd(newConfig, terminalCommand)
}
@@ -652,7 +652,7 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
newConfig := strings.ReplaceAll(HyprlandLuaConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if !useSystemd {
if !cd.systemdAutostart(deps.WindowManagerHyprland, useSystemd) {
newConfig = transformHyprlandLuaForNonSystemd(newConfig, terminalCommand)
}
@@ -671,14 +671,6 @@ func (cd *ConfigDeployer) deployHyprlandConfig(terminalCommand string, useSystem
return result, result.Error
}
if useSystemd {
if targetPath, err := EnsureHyprlandSessionTarget(); err != nil {
cd.log(fmt.Sprintf("Warning: failed to write hyprland-session.target: %v", err))
} else {
cd.log(fmt.Sprintf("Ensured hyprland session target at %s", targetPath))
}
}
movedLegacy, mainConfBackup, err := backupLegacyHyprlandConfFiles(configDir, dmsDir, backupDir)
if err != nil {
result.Error = fmt.Errorf("failed to back up legacy hyprlang configs: %w", err)
+83
View File
@@ -2,6 +2,7 @@ package config
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
@@ -12,6 +13,27 @@ import (
"github.com/stretchr/testify/require"
)
// Tests never reach the user's systemd; stubAddWants counts the add-wants calls a deploy makes.
func TestMain(m *testing.M) {
systemctlUser = func(...string) error { return nil }
os.Exit(m.Run())
}
func stubAddWants(t *testing.T, addWants error) *int {
t.Helper()
calls := 0
prev := systemctlUser
systemctlUser = func(args ...string) error {
if args[0] != "add-wants" {
return nil
}
calls++
return addWants
}
t.Cleanup(func() { systemctlUser = prev })
return &calls
}
func TestCleanupStrayHyprlandConfFile(t *testing.T) {
if os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") == "" {
t.Setenv("HYPRLAND_INSTANCE_SIGNATURE", "test-signature")
@@ -805,6 +827,67 @@ func TestDeployHyprlandConfigWritesLuaBeforeRemovingLegacyConf(t *testing.T) {
assert.Equal(t, filepath.Join(filepath.Dir(result.BackupPath), "hyprland.conf"), result.BackupPath)
}
func TestDeployHyprlandConfigSystemdAutostart(t *testing.T) {
cases := []struct {
name string
useSystemd bool
addWants error
wantExec bool
wantCalls int
wantLink bool
}{
{"systemd", true, nil, false, 1, true},
{"add-wants fails", true, errors.New("no user bus"), true, 1, true},
{"no systemd", false, nil, true, 0, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
calls := stubAddWants(t, tc.addWants)
link := WantsLink(HyprlandSessionTarget)
require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o755))
require.NoError(t, os.Symlink("/dev/null", link))
result, err := NewConfigDeployer(nil).deployHyprlandConfig("kitty", tc.useSystemd)
require.NoError(t, err)
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Equal(t, tc.wantExec, strings.Contains(string(content), `hl.exec_cmd("dms run")`))
assert.Equal(t, !tc.wantExec, strings.Contains(string(content), "systemctl --user start hyprland-session.target"))
assert.Equal(t, tc.wantCalls, *calls)
_, statErr := os.Lstat(link)
assert.Equal(t, tc.wantLink, statErr == nil)
})
}
}
func TestDeployNiriConfigSystemdAutostart(t *testing.T) {
cases := []struct {
name string
useSystemd bool
addWants error
wantSpawn bool
wantCalls int
}{
{"systemd", true, nil, false, 1},
{"add-wants fails", true, errors.New("no user bus"), true, 1},
{"no systemd", false, nil, true, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Setenv("HOME", t.TempDir())
calls := stubAddWants(t, tc.addWants)
result, err := NewConfigDeployer(nil).deployNiriConfig("kitty", tc.useSystemd)
require.NoError(t, err)
content, err := os.ReadFile(result.Path)
require.NoError(t, err)
assert.Equal(t, tc.wantSpawn, strings.Contains(string(content), `spawn-at-startup "dms" "run"`))
assert.Equal(t, tc.wantCalls, *calls)
})
}
}
func TestHyprlandLuaUnsupported(t *testing.T) {
for ver, refuse := range map[string]bool{
"0.54.2": true,
+1 -23
View File
@@ -3,7 +3,6 @@ package config
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
@@ -51,9 +50,6 @@ var (
var (
mangoDialect = mangoconf.Detect
mangoSessionTargetInstalled = mangoconf.SessionTargetInstalled
mangoAddWants = func() error {
return exec.Command("systemctl", "--user", "add-wants", mangoconf.SessionTarget, "dms").Run()
}
)
// MangoExistingBinds returns where the user's Mango binds live: a non-empty
@@ -88,24 +84,6 @@ func extractMangoLines(text string, match *regexp.Regexp) string {
return strings.Join(out, "\n") + "\n"
}
// mangoSystemdAutostart starts dms.service from mango-session.target and
// returns false when that is not possible, so the config keeps exec-once.
func (cd *ConfigDeployer) mangoSystemdAutostart(useSystemd bool) bool {
link := mangoconf.WantsLink()
if !useSystemd || !mangoSessionTargetInstalled() {
if err := os.Remove(link); err == nil {
cd.log("Removed dms.service from " + mangoconf.SessionTarget)
}
return false
}
if err := mangoAddWants(); err != nil {
cd.log(fmt.Sprintf("Warning: add-wants %s dms failed (%v); starting DMS from the Mango config instead", mangoconf.SessionTarget, err))
return false
}
cd.log("DMS starts as dms.service with " + mangoconf.SessionTarget)
return true
}
func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd bool) (DeploymentResult, error) {
configDir := mangoconf.Dir()
result := DeploymentResult{
@@ -134,7 +112,7 @@ func (cd *ConfigDeployer) deployMangoConfig(terminalCommand string, useSystemd b
dialect := mangoDialect()
newConfig := strings.ReplaceAll(MangoConfig, "{{TERMINAL_COMMAND}}", terminalCommand)
if cd.mangoSystemdAutostart(useSystemd) {
if cd.systemdAutostart(deps.WindowManagerMango, useSystemd) {
newConfig = mangoExecOnceDMS.ReplaceAllString(newConfig, "")
}
if err := os.WriteFile(result.Path, []byte(dialect.Translate(newConfig)), 0o644); err != nil {
+3 -5
View File
@@ -15,13 +15,11 @@ import (
func stubMango(t *testing.T, dialect mangoconf.Dialect, target bool, addWants error) *int {
t.Helper()
calls := 0
prevDialect, prevTarget, prevWants := mangoDialect, mangoSessionTargetInstalled, mangoAddWants
prevDialect, prevTarget := mangoDialect, mangoSessionTargetInstalled
mangoDialect = func() mangoconf.Dialect { return dialect }
mangoSessionTargetInstalled = func() bool { return target }
mangoAddWants = func() error { calls++; return addWants }
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled, mangoAddWants = prevDialect, prevTarget, prevWants })
return &calls
t.Cleanup(func() { mangoDialect, mangoSessionTargetInstalled = prevDialect, prevTarget })
return stubAddWants(t, addWants)
}
func readMango(t *testing.T, home, rel string) string {
+152
View File
@@ -0,0 +1,152 @@
package config
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/mangoconf"
)
// dms.service is wanted by the compositor's own session unit rather than enabled
// for graphical-session.target, so it never starts under GNOME or KDE on the same user.
const (
HyprlandSessionTarget = "hyprland-session.target"
NiriSessionUnit = "niri.service"
)
var sessionUnits = []string{HyprlandSessionTarget, NiriSessionUnit, mangoconf.SessionTarget}
var systemctlUser = func(args ...string) error {
output, err := exec.Command("systemctl", append([]string{"--user"}, args...)...).CombinedOutput()
if err == nil {
return nil
}
if detail := strings.TrimSpace(string(output)); detail != "" {
return fmt.Errorf("%s: %w", detail, err)
}
return err
}
// SessionUnitName is the unit dms.service is wanted by under wm, with no side effects.
func SessionUnitName(wm deps.WindowManager) string {
switch wm {
case deps.WindowManagerHyprland:
return HyprlandSessionTarget
case deps.WindowManagerNiri:
return NiriSessionUnit
case deps.WindowManagerMango:
return mangoconf.SessionTarget
}
return ""
}
// SessionUnit returns the unit dms.service should be wanted by under wm, making sure it
// exists. Hyprland before 0.57 gets our target written; Mango before 0.17.1 has none and yields "".
func SessionUnit(wm deps.WindowManager) (string, error) {
switch wm {
case deps.WindowManagerHyprland:
if _, err := EnsureHyprlandSessionTarget(); err != nil {
return "", fmt.Errorf("write %s: %w", HyprlandSessionTarget, err)
}
return HyprlandSessionTarget, nil
case deps.WindowManagerNiri:
return NiriSessionUnit, nil
case deps.WindowManagerMango:
if !mangoSessionTargetInstalled() {
return "", nil
}
return mangoconf.SessionTarget, nil
}
return "", fmt.Errorf("unsupported compositor %v", wm)
}
// WantsLink is the add-wants symlink that starts dms.service with unit.
func WantsLink(unit string) string {
return filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user", unit+".wants", "dms.service")
}
// SessionWanted lists the compositor units that currently pull dms.service in.
func SessionWanted() []string {
var wanted []string
for _, unit := range sessionUnits {
if _, err := os.Lstat(WantsLink(unit)); err == nil {
wanted = append(wanted, unit)
}
}
return wanted
}
// EnableSession makes wm's session unit want dms.service and returns that unit.
func EnableSession(wm deps.WindowManager) (string, error) {
unit, err := SessionUnit(wm)
if err != nil {
return "", err
}
if unit == "" {
return "", errors.New("this Mango has no mango-session.target (added in 0.17.1); start DMS from the Mango config instead")
}
if err := systemctlUser("add-wants", unit, "dms"); err != nil {
return unit, fmt.Errorf("add-wants %s dms: %w", unit, err)
}
return unit, nil
}
// DisableSession drops dms.service from every compositor session unit and returns the ones it was wanted by.
func DisableSession() ([]string, error) {
var removed []string
for _, unit := range sessionUnits {
if err := os.Remove(WantsLink(unit)); err != nil {
if os.IsNotExist(err) {
continue
}
return removed, err
}
removed = append(removed, unit)
}
if len(removed) == 0 {
return nil, nil
}
return removed, systemctlUser("daemon-reload")
}
// RunningCompositor identifies the compositor of the current session from its environment.
func RunningCompositor() (deps.WindowManager, bool) {
switch {
case os.Getenv("HYPRLAND_INSTANCE_SIGNATURE") != "":
return deps.WindowManagerHyprland, true
case os.Getenv("NIRI_SOCKET") != "":
return deps.WindowManagerNiri, true
case os.Getenv("MANGO_INSTANCE_SIGNATURE") != "":
return deps.WindowManagerMango, true
}
return 0, false
}
// systemdAutostart makes the compositor session start dms.service and returns false when
// that is not possible, so the deployed config keeps starting DMS itself.
func (cd *ConfigDeployer) systemdAutostart(wm deps.WindowManager, useSystemd bool) bool {
name := SessionUnitName(wm)
if !useSystemd {
if err := os.Remove(WantsLink(name)); err == nil {
cd.log("Removed dms.service from " + name)
}
return false
}
unit, err := EnableSession(wm)
if err != nil {
cd.log(fmt.Sprintf("Warning: %v; starting DMS from the compositor config instead", err))
if unit == "" {
if err := os.Remove(WantsLink(name)); err == nil {
cd.log("Removed dms.service from " + name)
}
}
return false
}
cd.log("DMS starts as dms.service with " + unit)
return true
}
@@ -0,0 +1,51 @@
package config
import (
"os"
"path/filepath"
"testing"
"github.com/AvengeMedia/DankMaterialShell/core/internal/deps"
"github.com/AvengeMedia/DankMaterialShell/core/internal/mangoconf"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestDisableSessionRemovesEveryWantsLink(t *testing.T) {
t.Setenv("HOME", t.TempDir())
for _, unit := range []string{HyprlandSessionTarget, mangoconf.SessionTarget} {
link := WantsLink(unit)
require.NoError(t, os.MkdirAll(filepath.Dir(link), 0o755))
require.NoError(t, os.Symlink("/dev/null", link))
}
var reloads int
prev := systemctlUser
systemctlUser = func(args ...string) error {
if args[0] == "daemon-reload" {
reloads++
}
return nil
}
t.Cleanup(func() { systemctlUser = prev })
assert.Equal(t, []string{HyprlandSessionTarget, mangoconf.SessionTarget}, SessionWanted())
removed, err := DisableSession()
require.NoError(t, err)
assert.Equal(t, []string{HyprlandSessionTarget, mangoconf.SessionTarget}, removed)
assert.Empty(t, SessionWanted())
assert.Equal(t, 1, reloads)
removed, err = DisableSession()
require.NoError(t, err)
assert.Nil(t, removed)
assert.Equal(t, 1, reloads)
}
func TestEnableSessionMangoWithoutTarget(t *testing.T) {
t.Setenv("HOME", t.TempDir())
calls := stubMango(t, mangoconf.Snake, false, nil)
unit, err := EnableSession(deps.WindowManagerMango)
require.Error(t, err)
assert.Empty(t, unit)
assert.Equal(t, 0, *calls)
}
+6 -11
View File
@@ -600,18 +600,13 @@ TERMINAL=%s
return nil
}
func (b *BaseDistribution) EnableDMSService(ctx context.Context, wm deps.WindowManager) error {
switch wm {
case deps.WindowManagerNiri:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "niri.service", "dms").Run(); err != nil {
b.log("Warning: failed to add dms as a want for niri.service")
}
case deps.WindowManagerHyprland:
if err := exec.CommandContext(ctx, "systemctl", "--user", "add-wants", "hyprland-session.target", "dms").Run(); err != nil {
b.log("Warning: failed to add dms as a want for hyprland-session.target")
}
func (b *BaseDistribution) EnableDMSService(_ context.Context, wm deps.WindowManager) error {
unit, err := config.EnableSession(wm)
if err != nil {
b.log(fmt.Sprintf("Warning: %v", err))
return nil
}
b.log("dms.service starts with " + unit)
return nil
}
-5
View File
@@ -46,8 +46,3 @@ func SessionTargetInstalled() bool {
}
return false
}
// WantsLink is the add-wants symlink that starts dms.service with the Mango session.
func WantsLink() string {
return filepath.Join(os.Getenv("HOME"), ".config", "systemd", "user", SessionTarget+".wants", "dms.service")
}