Files
mesh-catalog/modules/polychromatic/cmd/polychromatic-tools/polychromatic.go
T
jochen d0d5546088 Give the remaining tray applets their modules, each with one start
openrazer (the official driver, daemon and library), polychromatic (the
AUR tray, kept as found; its i3 line moves out of the i3 module into its
own node-display-session contribution), forticlient (the AUR VPN client:
its service declared, its configuration never read) and nm-applet (the
desktop half of NetworkManager, apart from the server-side module).

The openrazer daemon fails on both workstations because the account is
not in the openrazer group; openrazer_check names it and the README
carries the one-off step, since the account's user resource is zsh's.

desktop.go learns to tell a program from another sharing its 15-character
command name, so polychromatic's tools never count or end themselves, and
a copies test holds the six carriers to one text.
2026-10-05 15:10:30 +02:00

214 lines
7.7 KiB
Go

package main
// Polychromatic's tray as the tools see it: its processes, its one start (the module's line in the
// window manager's configuration), the package's login helper and the one setting of the application's
// that makes the helper start the tray too, and whether the daemon it drives answers. The daemon is
// the openrazer module's; it is asked with --auto-start=no, so a question never starts it.
import (
"encoding/json"
"fmt"
"strings"
"time"
)
const (
trayComm = "polychromatic-t" // the kernel keeps 15 characters of polychromatic-tray-applet
trayWord = "polychromatic-tray-applet"
trayBin = "/usr/bin/polychromatic-tray-applet"
helperEntry = "polychromatic-autostart.desktop"
restartAs = "mesh-polychromatic-tray"
packageFor = "polychromatic"
prefsFile = ".config/polychromatic/preferences.json"
settingName = "Preferences → Tray → \"Start the tray applet when I log on\""
)
// trays are the tray's processes. Its cut command name is this bundle's own too, so the program is
// matched by its full name.
func (m *Machine) trays() []Proc { return m.procsOf(trayComm, trayWord) }
// TraySetting is the application's own setting that makes its login helper start the tray. The
// module never writes it: the application rewrites its preferences file itself.
type TraySetting struct {
// Autostart is the setting; Polychromatic's default is on.
Autostart bool `json:"autostart"`
Delay int `json:"autostart_delay_seconds"`
From string `json:"from"`
}
// setting reads the tray keys of the preferences file and nothing else of it.
func (m *Machine) setting() TraySetting {
s := TraySetting{Autostart: true, From: "Polychromatic's default (no preferences file)"}
raw, err := readBounded(m.home(prefsFile))
if err != nil {
return s
}
var doc struct {
Tray struct {
Autostart *bool `json:"autostart"`
Delay int `json:"autostart_delay"`
} `json:"tray"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
s.From = "Polychromatic's default (the preferences file does not parse)"
return s
}
s.From = "~/" + prefsFile
if doc.Tray.Autostart != nil {
s.Autostart = *doc.Tray.Autostart
}
s.Delay = doc.Tray.Delay
return s
}
// Backend is whether the openrazer daemon answers, and how many devices it has.
type Backend struct {
Answers bool `json:"answers"`
Devices int `json:"devices"`
Unanswered string `json:"unanswered,omitempty"`
}
func (m *Machine) backend() Backend {
args := []string{"--user", "--auto-start=no", "--json=short", "call", "org.razer", "/org/razer", "razer.devices", "getDevices"}
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
if err := failed(o, "busctl", args...); err != nil {
return Backend{Unanswered: "the openrazer daemon does not answer (asked without starting it): " + err.Error()}
}
var doc struct {
Data [][]string `json:"data"`
}
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
return Backend{Unanswered: "the daemon's device list is not busctl's JSON"}
}
return Backend{Answers: true, Devices: len(doc.Data[0])}
}
// foreign says whether the package came from outside the official repositories (pacman -Qm).
func (m *Machine) foreign() bool {
o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
}
// StatusAnswer is what polychromatic_status answers.
type StatusAnswer struct {
Installed string `json:"installed,omitempty"`
Foreign bool `json:"outside_official_repositories"`
Tray []Proc `json:"tray"`
StartedBy []string `json:"started_by"`
Helper Autostart `json:"login_helper"`
Setting TraySetting `json:"tray_setting"`
Backend Backend `json:"backend"`
}
// Status reads the tray, what starts it, and whether the daemon behind it answers.
func (m *Machine) Status() (StatusAnswer, error) {
s := StatusAnswer{Tray: m.trays(), StartedBy: []string{}, Helper: m.autostart(helperEntry),
Setting: m.setting(), Backend: m.backend()}
if s.Tray == nil {
s.Tray = []Proc{}
}
v, err := m.installed(packageFor)
if err != nil {
return s, err
}
s.Installed = v
if v != "" {
s.Foreign = m.foreign()
}
for _, l := range m.i3Starts(trayWord) {
s.StartedBy = append(s.StartedBy, "window manager: "+l)
}
if s.Helper.Starts && s.Setting.Autostart {
s.StartedBy = append(s.StartedBy, "the login helper ("+s.Helper.From+"), as the tray setting is on")
}
return s, nil
}
// RestartAnswer is what polychromatic_restart answers.
type RestartAnswer struct {
Ended []int `json:"ended"`
Killed []int `json:"killed,omitempty"`
Running []Proc `json:"running"`
Session Session `json:"session"`
Unit string `json:"unit"`
}
// Restart ends the tray and starts it again in the operator's session, under the account's service
// manager.
func (m *Machine) Restart() (RestartAnswer, error) {
s, err := m.session()
if err != nil {
return RestartAnswer{}, err
}
a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
a.Ended, a.Killed = m.stopProcs(5*time.Second, m.trays())
if err := m.detach(s, restartAs, trayBin); err != nil {
return a, err
}
a.Running = m.waitForOf(trayComm, trayWord, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the tray was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, trayWord, a.Unit)
}
return a, nil
}
// CheckAnswer is what polychromatic_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies what the module promises and relies on: the package (found, not installed by the
// mesh); one start, the module's window-manager line; the tray running once; and the daemon answering.
func (m *Machine) Check() (CheckAnswer, error) {
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
v, err := m.installed(packageFor)
if err != nil {
return a, err
}
if v == "" {
add("polychromatic is not installed. It is outside the official repositories, so the mesh does not install it",
"install it from the AUR by hand; it brings the openrazer packages with it")
}
lines := m.i3Starts(trayWord)
for _, l := range lines {
a.Starts = append(a.Starts, "window manager: "+l)
}
switch {
case len(lines) == 0:
add("the window manager does not start the tray", "push the module: its line in the window manager's configuration is the tray's start")
case len(lines) > 1:
for _, l := range lines[1:] {
add("a second start: "+l, "remove the line; the module's line is the tray's one start")
}
}
helper := m.autostart(helperEntry)
if set := m.setting(); helper.Starts && set.Autostart {
a.Starts = append(a.Starts, "login helper: "+helper.From+" (the tray setting is on)")
add("a second start: the package's login helper starts the tray too, as the tray setting is on ("+set.From+")",
"in Polychromatic, untick "+settingName+". The helper keeps its other work: it resumes the devices' effects")
}
if own := m.autostart(trayWord + ".desktop"); own.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+own.From)
add("a second start: "+own.From, "remove it; the module's line is the tray's one start")
}
if _, err := m.session(); err == nil {
switch running := m.trays(); {
case len(running) == 0:
add("no tray runs in the desktop session", "polychromatic_restart")
case len(running) > 1:
add(fmt.Sprintf("%d trays run", len(running)), "polychromatic_restart ends them all and starts one")
}
}
if b := m.backend(); !b.Answers {
add(b.Unanswered+": the tray has no devices to show", "assign the openrazer module; openrazer_check says why the daemon is not running")
}
a.OK = len(a.Findings) == 0
return a, nil
}