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.
500 lines
17 KiB
Go
500 lines
17 KiB
Go
package main
|
|
|
|
// openrazer as the tools see it: the kernel driver (built by DKMS, loaded, holding devices), the
|
|
// account's membership of the group the driver gives its devices to, and the daemon in the account's
|
|
// service manager, asked over the session bus. Every bus call is made with --auto-start=no: the daemon
|
|
// is D-Bus activatable, and a question must never start it.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
dkmsName = "openrazer-driver"
|
|
group = "openrazer"
|
|
daemonComm = "openrazer-daemo" // the kernel keeps 15 characters of openrazer-daemon
|
|
daemonUnit = "openrazer-daemon.service"
|
|
busName = "org.razer"
|
|
busRoot = "/org/razer"
|
|
activation = "/usr/share/dbus-1/services/org.razer.service"
|
|
// mostDevices bounds the questions one answer asks the daemon.
|
|
mostDevices = 16
|
|
)
|
|
|
|
// packages are the module's, from the official repositories: the driver's DKMS source, the daemon,
|
|
// and the Python library every front end (the tray among them) speaks to the daemon through.
|
|
var packages = []string{"openrazer-driver-dkms", "openrazer-daemon", "python-openrazer"}
|
|
|
|
// kernelModules are what the DKMS package builds (its dkms.conf).
|
|
var kernelModules = []string{"razerkbd", "razermouse", "razerkraken", "razeraccessory"}
|
|
|
|
// hidID is a HID device's name under its driver: bus:vendor:product.instance.
|
|
// stamp is the daemon's own time at the front of its log lines, dropped so repeats are seen as one.
|
|
var stamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s*\|\s*`)
|
|
|
|
var hidID = regexp.MustCompile(`^[0-9A-Fa-f]{4}:([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})\.[0-9A-Fa-f]{4}$`)
|
|
|
|
// Bound is one device the driver holds: its USB id, the driver module and how many of its HID
|
|
// interfaces are bound.
|
|
type Bound struct {
|
|
USB string `json:"usb"`
|
|
Driver string `json:"driver"`
|
|
Interfaces int `json:"interfaces"`
|
|
}
|
|
|
|
// Driver is the kernel side.
|
|
type Driver struct {
|
|
Kernel string `json:"running_kernel"`
|
|
// Built is DKMS's word for the driver and the running kernel: "installed" when it is built and
|
|
// installed for it, "" when DKMS has nothing for it.
|
|
Built string `json:"built,omitempty"`
|
|
Loaded []string `json:"loaded"`
|
|
Bound []Bound `json:"bound"`
|
|
}
|
|
|
|
// Group is the account's membership of the group the driver's udev rules give the devices to.
|
|
type Group struct {
|
|
Name string `json:"name"`
|
|
Exists bool `json:"exists"`
|
|
Account bool `json:"account_in_group"`
|
|
// Manager is whether the account's running service manager has the group. It took its groups when
|
|
// it started, at the account's first login, and the daemon runs under it.
|
|
Manager *bool `json:"service_manager_has_it,omitempty"`
|
|
}
|
|
|
|
// Device is one device as the daemon sees it. Its serial is not answered.
|
|
type Device struct {
|
|
Name string `json:"name"`
|
|
Type string `json:"type,omitempty"`
|
|
Firmware string `json:"firmware,omitempty"`
|
|
Battery *float64 `json:"battery_percent,omitempty"`
|
|
Charging *bool `json:"charging,omitempty"`
|
|
}
|
|
|
|
// Daemon is the account's daemon.
|
|
type Daemon struct {
|
|
Active string `json:"unit_active"`
|
|
Enabled string `json:"unit_enabled"`
|
|
Running []Proc `json:"running"`
|
|
// StartedBy is what starts it: D-Bus activation of org.razer, through its unit.
|
|
StartedBy string `json:"started_by"`
|
|
Version string `json:"version,omitempty"`
|
|
Unanswered string `json:"unanswered,omitempty"`
|
|
LastWords []string `json:"last_words,omitempty"`
|
|
}
|
|
|
|
// StatusAnswer is what openrazer_status answers.
|
|
type StatusAnswer struct {
|
|
Installed map[string]string `json:"installed"`
|
|
Driver Driver `json:"driver"`
|
|
Group Group `json:"group"`
|
|
Daemon Daemon `json:"daemon"`
|
|
Devices []Device `json:"devices"`
|
|
Notes []string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// busCall asks the daemon one question and answers busctl's data.
|
|
func (m *Machine) busCall(path, iface, method string) (json.RawMessage, error) {
|
|
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, path, iface, method}
|
|
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
|
if err := failed(o, "busctl", args...); err != nil {
|
|
return nil, err
|
|
}
|
|
var doc struct {
|
|
Data []json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
|
return nil, fmt.Errorf("the daemon's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
|
|
}
|
|
return doc.Data[0], nil
|
|
}
|
|
|
|
func (m *Machine) busString(path, iface, method string) (string, error) {
|
|
raw, err := m.busCall(path, iface, method)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return "", fmt.Errorf("the daemon's %s is not a string: %w", method, err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// devices asks the daemon for the devices it holds, and each one's name, type, firmware and battery.
|
|
func (m *Machine) devices() ([]Device, string, error) {
|
|
version, err := m.busString(busRoot, "razer.daemon", "version")
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
raw, err := m.busCall(busRoot, "razer.devices", "getDevices")
|
|
if err != nil {
|
|
return nil, version, err
|
|
}
|
|
var serials []string
|
|
if err := json.Unmarshal(raw, &serials); err != nil {
|
|
return nil, version, fmt.Errorf("the daemon's device list is not a list of names: %w", err)
|
|
}
|
|
if len(serials) > mostDevices {
|
|
serials = serials[:mostDevices]
|
|
}
|
|
out := []Device{}
|
|
for _, serial := range serials {
|
|
path := busRoot + "/device/" + serial
|
|
d := Device{}
|
|
d.Name, _ = m.busString(path, "razer.device.misc", "getDeviceName")
|
|
d.Type, _ = m.busString(path, "razer.device.misc", "getDeviceType")
|
|
d.Firmware, _ = m.busString(path, "razer.device.misc", "getFirmware")
|
|
if raw, err := m.busCall(path, "razer.device.power", "getBattery"); err == nil {
|
|
var pct float64
|
|
if json.Unmarshal(raw, &pct) == nil {
|
|
d.Battery = &pct
|
|
}
|
|
}
|
|
if raw, err := m.busCall(path, "razer.device.power", "isCharging"); err == nil {
|
|
var on bool
|
|
if json.Unmarshal(raw, &on) == nil {
|
|
d.Charging = &on
|
|
}
|
|
}
|
|
if d.Name == "" {
|
|
d.Name = "(unnamed)"
|
|
}
|
|
out = append(out, d)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
|
return out, version, nil
|
|
}
|
|
|
|
// driver reads the kernel side: DKMS's state for the running kernel, the loaded modules, and the
|
|
// devices bound to them.
|
|
func (m *Machine) driver() Driver {
|
|
d := Driver{Kernel: readTrimmed(m.path("/proc/sys/kernel/osrelease")), Loaded: []string{}, Bound: []Bound{}}
|
|
if d.Kernel != "" {
|
|
o := m.cmd(0, nil, "dkms", "status", dkmsName, "-k", d.Kernel)
|
|
for _, l := range strings.Split(o.Stdout, "\n") {
|
|
if strings.HasPrefix(l, dkmsName+"/") && strings.Contains(l, d.Kernel) {
|
|
if i := strings.LastIndex(l, ":"); i > 0 {
|
|
d.Built = strings.TrimSpace(l[i+1:])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for _, k := range kernelModules {
|
|
if exists(m.path(filepath.Join("/sys/module", k))) {
|
|
d.Loaded = append(d.Loaded, k)
|
|
}
|
|
}
|
|
drivers, _ := os.ReadDir(m.path("/sys/bus/hid/drivers"))
|
|
seen := map[string]int{}
|
|
for _, drv := range drivers {
|
|
if !strings.HasPrefix(drv.Name(), "razer") {
|
|
continue
|
|
}
|
|
entries, _ := os.ReadDir(m.path(filepath.Join("/sys/bus/hid/drivers", drv.Name())))
|
|
for _, e := range entries {
|
|
if f := hidID.FindStringSubmatch(e.Name()); f != nil {
|
|
usb := strings.ToLower(f[1] + ":" + f[2])
|
|
if i, ok := seen[drv.Name()+" "+usb]; ok {
|
|
d.Bound[i].Interfaces++
|
|
continue
|
|
}
|
|
seen[drv.Name()+" "+usb] = len(d.Bound)
|
|
d.Bound = append(d.Bound, Bound{USB: usb, Driver: drv.Name(), Interfaces: 1})
|
|
}
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
// account is the operator account's name, from the user database by uid.
|
|
func (m *Machine) account() string {
|
|
raw, _ := readBounded(m.path("/etc/passwd"))
|
|
for _, l := range strings.Split(string(raw), "\n") {
|
|
f := strings.Split(l, ":")
|
|
if len(f) > 2 && f[2] == strconv.Itoa(m.UID) {
|
|
return f[0]
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// groupOf reads one group's gid and members from the group file.
|
|
func (m *Machine) groupOf(name string) (gid int, members []string, found bool) {
|
|
raw, _ := readBounded(m.path("/etc/group"))
|
|
for _, l := range strings.Split(string(raw), "\n") {
|
|
f := strings.Split(l, ":")
|
|
if len(f) == 4 && f[0] == name {
|
|
gid, _ = strconv.Atoi(f[2])
|
|
for _, who := range strings.Split(f[3], ",") {
|
|
if who = strings.TrimSpace(who); who != "" {
|
|
members = append(members, who)
|
|
}
|
|
}
|
|
return gid, members, true
|
|
}
|
|
}
|
|
return 0, nil, false
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if x == s {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// managerGroups are the supplementary groups of the account's own service manager; ok is false when
|
|
// it does not run.
|
|
func (m *Machine) managerGroups() (groups []int, ok bool) {
|
|
for _, p := range m.procs("systemd") {
|
|
dir := m.path(filepath.Join("/proc", strconv.Itoa(p.PID)))
|
|
if !strings.Contains(readTrimmed(filepath.Join(dir, "cgroup")), fmt.Sprintf("user@%d.service", m.UID)) {
|
|
continue
|
|
}
|
|
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
|
if f := strings.Fields(l); len(f) > 0 && f[0] == "Groups:" {
|
|
for _, g := range f[1:] {
|
|
if n, err := strconv.Atoi(g); err == nil {
|
|
groups = append(groups, n)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return groups, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (m *Machine) group() Group {
|
|
g := Group{Name: group}
|
|
gid, members, found := m.groupOf(group)
|
|
if !found {
|
|
return g
|
|
}
|
|
g.Exists = true
|
|
g.Account = contains(members, m.account())
|
|
if in, ok := m.managerGroups(); ok {
|
|
has := false
|
|
for _, n := range in {
|
|
has = has || n == gid
|
|
}
|
|
g.Manager = &has
|
|
}
|
|
return g
|
|
}
|
|
|
|
// unit answers the daemon's unit's active and enabled states in the account's service manager.
|
|
func (m *Machine) unit() (active, enabled string) {
|
|
word := func(verb string) string {
|
|
o := m.cmd(5*time.Second, m.bus(), "systemctl", "--user", verb, daemonUnit)
|
|
if o.Err != nil {
|
|
return "unknown"
|
|
}
|
|
if w := strings.TrimSpace(o.Stdout); w != "" {
|
|
return strings.Fields(w)[0]
|
|
}
|
|
return "unknown"
|
|
}
|
|
return word("is-active"), word("is-enabled")
|
|
}
|
|
|
|
// lastWords are the daemon's last lines in the account's journal, for a unit that failed: its critical
|
|
// and error lines when it wrote any (the reason, not the traceback of its exit), else the last lines.
|
|
func (m *Machine) lastWords() []string {
|
|
o := m.cmd(5*time.Second, m.bus(), "journalctl", "--user", "-u", daemonUnit, "-n", "20", "-o", "cat", "--no-pager")
|
|
var all, said []string
|
|
seen := map[string]bool{}
|
|
for _, l := range strings.Split(o.Stdout, "\n") {
|
|
if l = strings.TrimSpace(l); l == "" {
|
|
continue
|
|
}
|
|
l = tail(stamp.ReplaceAllString(l, ""), 300)
|
|
all = append(all, l)
|
|
if (strings.Contains(l, "CRITICAL") || strings.Contains(l, "ERROR")) && !seen[l] {
|
|
seen[l] = true
|
|
said = append(said, l)
|
|
}
|
|
}
|
|
if len(said) > 0 {
|
|
return said
|
|
}
|
|
return lastOf(all, 4)
|
|
}
|
|
|
|
func (m *Machine) daemon() (Daemon, []Device) {
|
|
d := Daemon{Running: m.procs(daemonComm), StartedBy: "D-Bus activation of " + busName + " through " + daemonUnit}
|
|
if d.Running == nil {
|
|
d.Running = []Proc{}
|
|
}
|
|
if !exists(m.path(activation)) {
|
|
d.StartedBy = "nothing: the package's activation file " + activation + " is missing"
|
|
}
|
|
d.Active, d.Enabled = m.unit()
|
|
if d.Active == "failed" {
|
|
d.LastWords = m.lastWords()
|
|
}
|
|
devices := []Device{}
|
|
if len(d.Running) == 0 {
|
|
d.Unanswered = "the daemon is not running (asked without starting it)"
|
|
return d, devices
|
|
}
|
|
got, version, err := m.devices()
|
|
d.Version = version
|
|
if err != nil {
|
|
d.Unanswered = err.Error()
|
|
return d, devices
|
|
}
|
|
return d, got
|
|
}
|
|
|
|
// Status reads the whole stack.
|
|
func (m *Machine) Status() (StatusAnswer, error) {
|
|
s := StatusAnswer{Installed: map[string]string{}, Driver: m.driver(), Group: m.group()}
|
|
for _, p := range packages {
|
|
v, err := m.installed(p)
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
s.Installed[p] = v
|
|
}
|
|
s.Daemon, s.Devices = m.daemon()
|
|
if _, members, ok := m.groupOf("plugdev"); ok && contains(members, m.account()) {
|
|
s.Notes = append(s.Notes, "the account is in plugdev, which this openrazer does not use: its udev rules "+
|
|
"give the devices to the openrazer group. Other devices' rules may use plugdev; the module leaves it")
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// RestartAnswer is what openrazer_restart answers.
|
|
type RestartAnswer struct {
|
|
Active string `json:"unit_active"`
|
|
Running []Proc `json:"running"`
|
|
Version string `json:"version,omitempty"`
|
|
Devices []Device `json:"devices"`
|
|
}
|
|
|
|
// Restart restarts the daemon's unit in the account's service manager and waits for it to answer.
|
|
func (m *Machine) Restart() (RestartAnswer, error) {
|
|
a := RestartAnswer{Devices: []Device{}}
|
|
args := []string{"--user", "restart", daemonUnit}
|
|
if err := failed(m.cmd(15*time.Second, m.bus(), "systemctl", args...), "systemctl", args...); err != nil {
|
|
words := m.lastWords()
|
|
return a, fmt.Errorf("the daemon did not start: %v. Its last words: %s. See openrazer_check", err, strings.Join(words, " | "))
|
|
}
|
|
a.Active, _ = m.unit()
|
|
a.Running = m.waitFor(daemonComm, 4*time.Second)
|
|
devices, version, err := m.devices()
|
|
a.Version = version
|
|
if err != nil {
|
|
return a, fmt.Errorf("the daemon started but does not answer: %v", err)
|
|
}
|
|
a.Devices = devices
|
|
return a, nil
|
|
}
|
|
|
|
// CheckAnswer is what openrazer_check answers.
|
|
type CheckAnswer struct {
|
|
OK bool `json:"ok"`
|
|
Findings []Finding `json:"findings"`
|
|
Starts []string `json:"starts"`
|
|
}
|
|
|
|
// Check verifies the stack from the packages to the devices the daemon sees.
|
|
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}) }
|
|
for _, p := range packages {
|
|
v, err := m.installed(p)
|
|
if err != nil {
|
|
return a, err
|
|
}
|
|
if v == "" {
|
|
add("the package "+p+" is not installed", "push the module to the node")
|
|
}
|
|
}
|
|
|
|
d := m.driver()
|
|
switch {
|
|
case d.Kernel == "":
|
|
add("the running kernel's release is unreadable", "")
|
|
case d.Built != "installed":
|
|
add("the driver is not built for the running kernel "+d.Kernel+" (DKMS: "+orNone(d.Built)+")",
|
|
"install the headers of the running kernel (linux-headers), then `sudo dkms autoinstall`; or reboot into the kernel it was built for")
|
|
}
|
|
if len(d.Bound) == 0 {
|
|
add("no Razer device is bound to the driver", "plug the device in; if it is plugged in, openrazer_status shows which driver modules are loaded")
|
|
}
|
|
if len(d.Loaded) == 0 && d.Built == "installed" {
|
|
add("none of the driver's modules is loaded", "plug the device in, which loads its module, or `sudo modprobe razermouse` (razerkbd, razerkraken, razeraccessory)")
|
|
}
|
|
|
|
g := m.group()
|
|
switch {
|
|
case !g.Exists:
|
|
add("the group openrazer does not exist", "it comes with openrazer-driver-dkms (sysusers): reinstall it, or `sudo systemd-sysusers`")
|
|
case !g.Account:
|
|
add("the account is not in the openrazer group: the daemon refuses to start, and the devices' files are the group's",
|
|
"`sudo gpasswd -a $USER openrazer`, then log out of every session (or reboot)")
|
|
case g.Manager != nil && !*g.Manager:
|
|
add("the account is in the openrazer group, but its running service manager started before it was",
|
|
"log out of every session (or reboot), so the service manager starts again with the group")
|
|
}
|
|
|
|
if exists(m.path(activation)) {
|
|
a.Starts = append(a.Starts, "D-Bus activation: "+busName+" through "+daemonUnit)
|
|
} else {
|
|
add("the package's activation file "+activation+" is missing: nothing starts the daemon", "reinstall openrazer-daemon")
|
|
}
|
|
for _, l := range m.i3Starts("openrazer-daemon") {
|
|
a.Starts = append(a.Starts, "window manager: "+l)
|
|
add("a second start: "+l, "remove the line; D-Bus activation starts the daemon when a client asks")
|
|
}
|
|
|
|
daemon, devices := m.daemon()
|
|
switch {
|
|
case len(daemon.Running) == 0 && daemon.Active == "failed":
|
|
add("the daemon failed at its last start: "+strings.Join(lastOf(daemon.LastWords, 2), " | "), "fix what it says, then openrazer_restart")
|
|
case len(daemon.Running) == 0:
|
|
add("the daemon is not running", "openrazer_restart; at login the tray's helper asks for it, which starts it")
|
|
case len(daemon.Running) > 1:
|
|
add(fmt.Sprintf("%d daemons run", len(daemon.Running)), "openrazer_restart ends them and starts one, from its unit")
|
|
default:
|
|
if in := daemon.Running[0].StartedIn; in != daemonUnit {
|
|
add("the daemon runs outside its unit (in "+in+"): started by hand or by a second start", "openrazer_restart")
|
|
}
|
|
}
|
|
if daemon.Unanswered != "" && len(daemon.Running) > 0 {
|
|
add("the daemon does not answer: "+daemon.Unanswered, "openrazer_restart")
|
|
}
|
|
if len(daemon.Running) > 0 && daemon.Unanswered == "" && len(devices) < len(d.Bound) {
|
|
add(fmt.Sprintf("the driver holds %d device(s) and the daemon sees %d", len(d.Bound), len(devices)), "openrazer_restart")
|
|
}
|
|
a.OK = len(a.Findings) == 0
|
|
return a, nil
|
|
}
|
|
|
|
func orNone(s string) string {
|
|
if s == "" {
|
|
return "nothing for it"
|
|
}
|
|
return s
|
|
}
|
|
|
|
func lastOf(lines []string, n int) []string {
|
|
if len(lines) <= n {
|
|
return lines
|
|
}
|
|
return lines[len(lines)-n:]
|
|
}
|