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.
This commit is contained in:
jochen
2026-10-05 15:10:30 +02:00
parent 2a99b3c3e9
commit d0d5546088
59 changed files with 6591 additions and 50 deletions
@@ -0,0 +1,37 @@
package main
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
import (
"bytes"
"os"
"path/filepath"
"testing"
)
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
compared := 0
for _, module := range carriers {
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
if _, err := os.Stat(dir); err != nil {
continue
}
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
mine, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
theirs, err := os.ReadFile(filepath.Join(dir, f))
if err != nil || !bytes.Equal(mine, theirs) {
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
}
}
compared++
}
if compared == 0 {
t.Log("no sibling copies beside this module")
}
}
@@ -0,0 +1,601 @@
package main
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
// holds them to one text): a tray application of the operator's graphical session, seen from the
// node's tool runtime (novox/hq ADR 0208).
//
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
// uid and none of the session's environment. A tool that starts something on the desktop finds the
// session from a process of the account that carries DISPLAY (the window manager first), and starts
// the program under the account's own service manager with `systemd-run --user`, never as its own
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
//
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
// its signals are injected, so the tests run against a fake /proc and a fake home.
//
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
// to at most MostRead.
import (
"bufio"
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Bounds every command and read is held to.
const (
CallTimeout = 10 * time.Second
MostOutput = 256 << 10
MostRead = 16 << 20
)
// Output is what a command did.
type Output struct {
Stdout string
Stderr string
Code int
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
Err error
Cut bool
}
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
var (
ErrNotInstalled = errors.New("not installed")
ErrTimedOut = errors.New("timed out")
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
ErrNoSession = errors.New("no graphical session")
)
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
// Machine is what the tools read and act on.
type Machine struct {
Root string // "" on the machine; a fake root in tests
Home string // the operator's home, as the machine names it
UID int
Run Runner
Kill func(pid int, sig syscall.Signal) error
Sleep func(time.Duration)
Now func() time.Time
Timeout time.Duration
}
// NewMachine is the machine the bundle runs on.
func NewMachine() *Machine {
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
}
// operatorHome is the account's home: what the runtime was told, else the process's own.
func operatorHome() string {
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
return h
}
h, _ := os.UserHomeDir()
return h
}
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
// home is a path under the operator's home, on this machine's filesystem.
func (m *Machine) home(rel ...string) string {
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
}
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
func (m *Machine) tilde(p string) string {
if m.Home != "" && m.Home != "/" {
h := strings.TrimSuffix(m.Home, "/")
if p == h {
return "~"
}
if strings.HasPrefix(p, h+"/") {
return "~/" + strings.TrimPrefix(p, h+"/")
}
}
return p
}
// cmd runs a command within the machine's timeout (or a shorter one).
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
if timeout <= 0 || timeout > m.Timeout {
timeout = m.Timeout
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
return m.Run(ctx, env, name, args...)
}
// failed names how a command failed, or answers nil when it ran and exited 0.
func failed(o Output, name string, args ...string) error {
switch {
case errors.Is(o.Err, ErrNotInstalled):
return fmt.Errorf("%s is not installed on this machine", name)
case errors.Is(o.Err, ErrTimedOut):
return fmt.Errorf("%s gave no answer in time and was ended", name)
case o.Err != nil:
return fmt.Errorf("%s did not run: %v", name, o.Err)
case o.Code != 0:
said := strings.TrimSpace(o.Stderr)
if said == "" {
said = strings.TrimSpace(o.Stdout)
}
if said == "" {
said = "and said nothing"
}
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
}
return nil
}
func tail(s string, n int) string {
if len(s) <= n {
return s
}
return "…" + s[len(s)-n:]
}
type capped struct {
b bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := MostOutput - c.b.Len(); room < len(p) {
if room > 0 {
c.b.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.b.Write(p)
}
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
path, err := exec.LookPath(name)
if err != nil {
return Output{Code: 127, Err: ErrNotInstalled}
}
cmd := exec.CommandContext(ctx, path, args...)
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
// Its own process group, so that ending it on a timeout ends what it started too.
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
cmd.Cancel = func() error {
if cmd.Process != nil {
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
}
return nil
}
cmd.WaitDelay = 2 * time.Second
var out, errs capped
cmd.Stdout, cmd.Stderr = &out, &errs
err = cmd.Run()
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
var exit *exec.ExitError
switch {
case err == nil:
case ctx.Err() == context.DeadlineExceeded:
o.Code, o.Err = 124, ErrTimedOut
case errors.As(err, &exit):
o.Code = exit.ExitCode()
default:
o.Code, o.Err = 127, err
}
return o
}
// readBounded reads a file to at most MostRead bytes.
func readBounded(path string) ([]byte, error) {
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
return io.ReadAll(io.LimitReader(f, MostRead))
}
// Proc is one process of the account.
type Proc struct {
PID int `json:"pid"`
Command string `json:"command"`
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
StartedIn string `json:"started_in,omitempty"`
Since string `json:"since,omitempty"`
}
// procs are this account's processes named comm, oldest first.
func (m *Machine) procs(comm string) []Proc {
entries, err := os.ReadDir(m.path("/proc"))
if err != nil {
return nil
}
boot := m.bootTime()
var out []Proc
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
continue
}
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
if p.Command == "" {
p.Command = comm
}
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
line := strings.Split(cg, "\n")[0]
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
}
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
p.Since = t.UTC().Format(time.RFC3339)
}
out = append(out, p)
}
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
return out
}
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
// characters of a command name, so a longer name can share them with another program's: this bundle's
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
// The program is the first word of the command line, or the second for a script run by its
// interpreter. An empty word keeps every process named comm.
func (m *Machine) procsOf(comm, word string) []Proc {
var out []Proc
for _, p := range m.procs(comm) {
f := strings.Fields(p.Command)
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
out = append(out, p)
}
}
return out
}
// uidOf is the real uid on a process's status, -1 when unreadable.
func (m *Machine) uidOf(dir string) int {
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
if n, err := strconv.Atoi(f[1]); err == nil {
return n
}
}
}
return -1
}
func (m *Machine) bootTime() int64 {
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
n, _ := strconv.ParseInt(f[1], 10, 64)
return n
}
}
return 0
}
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
func startOf(stat string, boot int64) (time.Time, bool) {
i := strings.LastIndexByte(stat, ')')
if i < 0 || boot == 0 {
return time.Time{}, false
}
f := strings.Fields(stat[i+1:])
if len(f) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(f[19], 10, 64)
if err != nil {
return time.Time{}, false
}
return time.Unix(boot+ticks/100, 0), true
}
func readTrimmed(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
func exists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// Session is what a tool needs to start something on the operator's desktop.
type Session struct {
Display string `json:"display"`
XAuthority string `json:"xauthority,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
From string `json:"found_in"`
}
// sessionHolders are the processes whose environment is the session's, best first.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
func (m *Machine) session() (Session, error) {
entries, _ := os.ReadDir(m.path("/proc"))
best, bestRank := -1, len(sessionHolders)+1
var env map[string]string
var from string
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := m.path(filepath.Join("/proc", e.Name()))
if m.uidOf(dir) != m.UID {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
vars := parseEnviron(raw)
if vars["DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
}
}
if rank < bestRank || (rank == bestRank && pid > best) {
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
}
}
if env == nil {
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
}
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
if s.RuntimeDir == "" {
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
}
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s, nil
}
// bus is the account's session bus environment, which a logged-in account has with or without a
// desktop: what a command needs to reach the user's service manager or a bus name.
func (m *Machine) bus() []string {
runtime := fmt.Sprintf("/run/user/%d", m.UID)
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
}
// Env is the session's variables, for a command that draws or speaks to the desktop.
func (s Session) Env() []string {
var env []string
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
if kv[1] != "" {
env = append(env, kv[0]+"="+kv[1])
}
}
return env
}
func parseEnviron(raw []byte) map[string]string {
env := map[string]string{}
for _, kv := range bytes.Split(raw, []byte{0}) {
if i := bytes.IndexByte(kv, '='); i > 0 {
env[string(kv[:i])] = string(kv[i+1:])
}
}
return env
}
// detach starts a long-lived program under the account's service manager, as a transient unit that
// carries the session's display. A unit left by an earlier start under the same name is stopped
// first, so the fixed name means at most one.
func (m *Machine) detach(s Session, unit string, argv ...string) error {
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(append(call, "--"), argv...)
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
}
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
// there after grace. It answers the pids that ended and those that had to be killed.
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
var ps []Proc
for _, c := range comms {
ps = append(ps, m.procs(c)...)
}
return m.stopProcs(grace, ps)
}
// stopProcs ends the processes given, as stop does.
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
var pids []int
for _, p := range ps {
if m.Kill(p.PID, syscall.SIGTERM) == nil {
pids = append(pids, p.PID)
}
}
alive := func() []int {
var left []int
for _, pid := range pids {
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
left = append(left, pid)
}
}
return left
}
step := 200 * time.Millisecond
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
m.Sleep(step)
}
left := alive()
for _, pid := range left {
if m.Kill(pid, syscall.SIGKILL) == nil {
killed = append(killed, pid)
}
}
gone := map[int]bool{}
for _, pid := range left {
gone[pid] = true
}
for _, pid := range pids {
if !gone[pid] {
ended = append(ended, pid)
}
}
return ended, killed
}
// waitFor waits up to d for a process of the account named comm, and answers what it found.
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
}
}
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
func desktopEntry(path string) map[string]string {
raw, err := readBounded(path)
if err != nil {
return nil
}
out := map[string]string{}
in := false
s := bufio.NewScanner(bytes.NewReader(raw))
for s.Scan() {
l := strings.TrimSpace(s.Text())
switch {
case strings.HasPrefix(l, "["):
in = l == "[Desktop Entry]"
case in && l != "" && !strings.HasPrefix(l, "#"):
if i := strings.IndexByte(l, '='); i > 0 {
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
}
}
}
return out
}
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
type Autostart struct {
Entry string `json:"entry"`
From string `json:"from"`
Exec string `json:"exec,omitempty"`
Starts bool `json:"starts"`
Because string `json:"because,omitempty"`
}
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
func (m *Machine) autostart(name string) Autostart {
a := Autostart{Entry: name}
user := m.home(".config", "autostart", name)
system := m.path(filepath.Join("/etc/xdg/autostart", name))
var e map[string]string
switch {
case exists(user):
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
case exists(system):
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
default:
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
return a
}
a.Exec = e["Exec"]
switch {
case strings.EqualFold(e["Hidden"], "true"):
a.Because = "Hidden=true"
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
a.Because = "X-GNOME-Autostart-enabled=false"
case a.Exec == "":
a.Because = "the entry has no Exec"
default:
a.Starts = true
}
return a
}
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
// word, in the configuration and its config.d: a second start beside an autostart entry.
func (m *Machine) i3Starts(word string) []string {
files := []string{m.home(".config", "i3", "config")}
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
files = append(files, more...)
var out []string
for _, f := range files {
raw, err := readBounded(f)
if err != nil {
continue
}
for n, l := range strings.Split(string(raw), "\n") {
t := strings.TrimSpace(l)
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
continue
}
for _, w := range strings.Fields(t)[1:] {
if filepath.Base(strings.Trim(w, `"'`)) == word {
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
break
}
}
}
}
return out
}
// installed asks the package manager for one package's version; "" when it is not installed.
func (m *Machine) installed(pkg string) (string, error) {
o := m.cmd(0, nil, "pacman", "-Q", pkg)
if o.Err != nil {
return "", failed(o, "pacman", "-Q", pkg)
}
if o.Code != 0 {
return "", nil
}
f := strings.Fields(o.Stdout)
if len(f) < 2 {
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
}
return f[1], nil
}
// Finding is one thing a check found wrong, and what to do about it.
type Finding struct {
What string `json:"what"`
Do string `json:"do,omitempty"`
}
@@ -0,0 +1,219 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
// application's bundle (copies_test.go).
import (
"context"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"testing"
"time"
)
const testHome = "/home/operator"
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
type fake struct {
*Machine
t *testing.T
mu sync.Mutex
calls []string
answer func(name string, args []string) Output
// onStart is run when systemd-run starts something, to let a fake process appear.
onStart func(argv []string)
// stubborn pids ignore SIGTERM.
stubborn map[int]bool
signals []string
}
func newFake(t *testing.T) *fake {
t.Helper()
root := t.TempDir()
f := &fake{t: t, stubborn: map[int]bool{}}
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
f.mu.Lock()
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
f.mu.Unlock()
if name == "systemd-run" && f.onStart != nil {
for i, a := range args {
if a == "--" {
f.onStart(args[i+1:])
}
}
}
if f.answer != nil {
return f.answer(name, args)
}
return Output{}
}
f.Kill = func(pid int, sig syscall.Signal) error {
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
if sig == syscall.SIGKILL || !f.stubborn[pid] {
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
}
return nil
}
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
return f
}
func (f *fake) write(path, content string) {
f.t.Helper()
p := filepath.Join(f.Root, path)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
f.t.Fatal(err)
}
}
// proc adds a process of uid with a command name, argv, cgroup and environment.
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
d := "/proc/" + strconv.Itoa(pid) + "/"
f.write(d+"comm", comm+"\n")
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
// starttime (field 22) is 1000 ticks: 10 s after boot.
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
}
func (f *fake) desktopSession() {
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
f.write("/run/user/1000/bus", "")
}
func (f *fake) called(prefix string) bool {
for _, c := range f.calls {
if strings.HasPrefix(c, prefix) {
return true
}
}
return false
}
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
got := f.procs("worker")
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
t.Fatalf("%+v", got)
}
}
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
f := newFake(t)
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
t.Fatalf("%+v", got)
}
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
t.Fatalf("%+v", got)
}
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
t.Fatalf("ended %v; the tools' own process must stay", ended)
}
}
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
f := newFake(t)
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("%v", err)
}
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
f.desktopSession()
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
s, err := f.session()
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
!strings.Contains(s.From, "i3") {
t.Fatalf("%+v %v", s, err)
}
}
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
f := newFake(t)
f.desktopSession()
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
f.stubborn[21] = true
ended, killed := f.stop(time.Second, "app")
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
}
s, _ := f.session()
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
t.Fatal(err)
}
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
"/.Xauthority -- /usr/bin/app --background"
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
t.Fatalf("%q", f.calls)
}
}
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
f := newFake(t)
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
t.Fatalf("%+v", a)
}
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
t.Fatalf("%+v", a)
}
}
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
f := newFake(t)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
got := f.i3Starts("app")
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
t.Fatalf("%q", got)
}
}
func TestACommandThatFailsIsNamed(t *testing.T) {
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
t.Fatal(err)
}
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
t.Fatal(err)
}
if err := failed(Output{}, "true"); err != nil {
t.Fatal(err)
}
}
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
t.Fatalf("%+v", o)
}
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
t.Fatalf("%+v", o)
}
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
if !o.Cut || len(o.Stdout) != MostOutput {
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
}
}
@@ -0,0 +1,50 @@
// The openrazer module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
// peripherals' kernel driver and the account's daemon that speaks to it, served by the node's runtime
// as the operator account. The module holds no seat, so every tool is its own. The tray that drives
// the daemon is the polychromatic module's.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
var machine = NewMachine()
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "openrazer_status",
Description: "The Razer stack: the installed versions, the driver (built by DKMS for the running " +
"kernel or not, its kernel modules loaded, the devices bound to it), the account's openrazer " +
"group (in the group file, and in its running service manager), the daemon (its unit's state, " +
"its process, its version) and each device the daemon sees: name, type, firmware, battery. " +
"Asks the daemon without starting it. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "openrazer_restart",
Description: "Restart the daemon through its unit in the account's service manager (the unit D-Bus " +
"activation starts), and answer its state and the devices it then sees. Needs the account " +
"logged in. (a)",
Run: func(map[string]any) (any, error) { return machine.Restart() },
},
{
Name: "openrazer_check",
Description: "Check what the module promises and relies on: the packages are installed; the driver " +
"is built for the running kernel and loaded; the account is in the openrazer group, and its " +
"service manager has it; the daemon runs once, from its unit, and sees the devices the driver " +
"has. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,112 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// openrazer's shape (novox/hq ADR 0207, ADR 0208, ADR 0210): the three official packages of the
// driver, the daemon and its client library, no seat, no display (the daemon needs none), no start of
// its own (D-Bus activation through the package's unit is the one start), nothing of the tray's (the
// polychromatic module's), and the Go bundle serving exactly the listed openrazer_ tools.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Tools []string `json:"tools"`
Resources []map[string]any `json:"resources"`
Claims []any `json:"claims"`
Seats []any `json:"seats"`
Shell []any `json:"shell"`
Contributions []struct {
Seat string `json:"seat"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"contributions"`
Environment any `json:"environment"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
func readManifest(t *testing.T) (manifest, string) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
dec := json.NewDecoder(strings.NewReader(string(raw)))
dec.DisallowUnknownFields()
var m manifest
if err := dec.Decode(&m); err != nil {
t.Fatalf("module.json: %v", err)
}
return m, string(raw)
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m, raw := readManifest(t)
served := map[string]bool{}
for _, tool := range tools() {
served[tool.Name] = true
if !strings.HasPrefix(tool.Name, "openrazer_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed %s and described", tool.Name, "openrazer_")
}
}
for _, name := range m.Tools {
if !served[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
delete(served, name)
}
for name := range served {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if len(m.Build.Artifacts) != 1 {
t.Fatalf("%v", m.Build.Artifacts)
}
b := m.Build.Artifacts[0]
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
b["from"] != "cmd/openrazer-tools" || b["binary"] != "openrazer-tools" {
t.Errorf("the Go tools bundle: %v", b)
}
s := strings.ToLower(raw)
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
func TestItInstallsTheStackAndStartsNothing(t *testing.T) {
m, raw := readManifest(t)
if m.Module != "openrazer" || m.Requires != nil || !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
t.Fatalf("%+v", m)
}
var pkgs []string
for _, r := range m.Resources {
if r["type"] != "package" {
t.Errorf("only packages: %v", r)
}
pkgs = append(pkgs, r["package"].(string))
}
if !reflect.DeepEqual(pkgs, packages) {
t.Fatalf("%v", pkgs)
}
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
t.Fatal("it holds no seat, sets no environment and adds no start")
}
// The tray is polychromatic's, from outside the official repositories; the kernel headers DKMS
// builds against are the kernel's; the account's groups are its user resource's (zsh's).
for _, never := range []string{"polychromatic", "linux-headers", "\"dkms\"", "\"user\"", "groups"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %s", never)
}
}
}
@@ -0,0 +1,499 @@
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:]
}
@@ -0,0 +1,185 @@
package main
import (
"encoding/json"
"strings"
"testing"
)
const serial = "PM2148H00000001"
// newStack is a machine with the stack as it should be: built and loaded, a mouse bound on three
// interfaces, the account in the group (and its service manager with it), the daemon running from its
// unit and answering. member and running take parts of it away.
func newStack(t *testing.T, member, running bool) *fake {
f := newFake(t)
f.write("/proc/sys/kernel/osrelease", "7.2.8-arch1-2\n")
f.write("/sys/module/razermouse/refcnt", "0\n")
for _, i := range []string{"0004", "0005", "0006"} {
f.write("/sys/bus/hid/drivers/razermouse/0003:1532:00AB."+i+"/device_type", "")
}
f.write("/sys/bus/hid/drivers/razermouse/bind", "")
f.write("/etc/passwd", "root:x:0:0::/root:/bin/sh\noperator:x:1000:1000::/home/operator:/bin/zsh\n")
members := "operator"
if !member {
members = ""
}
f.write("/etc/group", "plugdev:x:970:operator\nopenrazer:x:958:"+members+"\n")
f.write(activation, "[D-BUS Service]\nName=org.razer\nSystemdService=openrazer-daemon.service\n")
f.proc(3596, 1000, "systemd", []string{"/usr/lib/systemd/systemd", "--user"}, "user@1000.service/init.scope")
groups := "970 958"
if !member {
groups = "970"
}
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t"+groups+"\n")
if running {
f.proc(4269, 1000, daemonComm, []string{"openrazer-daemon", "-F"}, "user@1000.service/app.slice/"+daemonUnit)
}
f.answer = func(name string, args []string) Output {
call := name + " " + strings.Join(args, " ")
switch {
case name == "pacman":
return Output{Stdout: args[1] + " 3.12.4-1\n"}
case name == "dkms":
return Output{Stdout: "openrazer-driver/3.12.4, 7.2.8-arch1-2, x86_64: installed\n"}
case name == "systemctl" && strings.Contains(call, "is-active"):
if running {
return Output{Stdout: "active\n"}
}
return Output{Stdout: "failed\n", Code: 3}
case name == "systemctl" && strings.Contains(call, "is-enabled"):
return Output{Stdout: "disabled\n", Code: 1}
case name == "journalctl":
return Output{Stdout: "Starting daemon.\n2026-10-04 16:26:21 | razer | CRITICAL | User is not a member of the openrazer group\n" +
"Traceback (most recent call last):\n2026-10-04 16:26:22 | razer | CRITICAL | User is not a member of the openrazer group\nSystemExit: 0\n"}
case name == "busctl" && !running:
return Output{Code: 1, Stderr: "Call failed: The name is not activatable"}
case strings.HasSuffix(call, " version"):
return Output{Stdout: `{"type":"s","data":["3.12.4"]}`}
case strings.HasSuffix(call, " getDevices"):
return Output{Stdout: `{"type":"as","data":[["` + serial + `"]]}`}
case strings.HasSuffix(call, " getDeviceName"):
return Output{Stdout: `{"type":"s","data":["Razer Basilisk V3 Pro"]}`}
case strings.HasSuffix(call, " getDeviceType"):
return Output{Stdout: `{"type":"s","data":["mouse"]}`}
case strings.HasSuffix(call, " getFirmware"):
return Output{Stdout: `{"type":"s","data":["v1.04"]}`}
case strings.HasSuffix(call, " getBattery"):
return Output{Stdout: `{"type":"d","data":[85.0]}`}
case strings.HasSuffix(call, " isCharging"):
return Output{Code: 1, Stderr: "Unknown method"}
}
return Output{}
}
return f
}
func TestStatusReadsTheDriverTheGroupAndTheDaemonWithoutStartingIt(t *testing.T) {
f := newStack(t, true, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed["openrazer-daemon"] != "3.12.4-1" || s.Driver.Built != "installed" || strings.Join(s.Driver.Loaded, ",") != "razermouse" ||
len(s.Driver.Bound) != 1 || s.Driver.Bound[0] != (Bound{USB: "1532:00ab", Driver: "razermouse", Interfaces: 3}) {
t.Fatalf("%+v", s)
}
if !s.Group.Exists || !s.Group.Account || s.Group.Manager == nil || !*s.Group.Manager {
t.Fatalf("%+v", s.Group)
}
if s.Daemon.Version != "3.12.4" || len(s.Daemon.Running) != 1 || len(s.Devices) != 1 {
t.Fatalf("%+v %+v", s.Daemon, s.Devices)
}
d := s.Devices[0]
if d.Name != "Razer Basilisk V3 Pro" || d.Type != "mouse" || d.Battery == nil || *d.Battery != 85 || d.Charging != nil {
t.Fatalf("%+v", d)
}
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "plugdev") {
t.Fatalf("%v", s.Notes)
}
for _, c := range f.calls {
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
t.Fatalf("a bus call that could start the daemon: %s", c)
}
}
raw, _ := json.Marshal(s)
if strings.Contains(string(raw), serial) {
t.Fatal("the answer carries a device's serial")
}
stopped := newStack(t, false, false)
s, _ = stopped.Status()
if s.Group.Account || s.Group.Manager == nil || *s.Group.Manager || s.Daemon.Active != "failed" || stopped.called("busctl") {
t.Fatalf("%+v %q", s, stopped.calls)
}
if len(s.Daemon.LastWords) != 1 || s.Daemon.LastWords[0] != "razer | CRITICAL | User is not a member of the openrazer group" {
t.Fatalf("the reason, once, not the traceback: %q", s.Daemon.LastWords)
}
}
func TestCheckPassesTheWholeStackAndNamesWhatIsMissing(t *testing.T) {
f := newStack(t, true, true)
c, err := f.Check()
if err != nil || !c.OK || len(c.Starts) != 1 || !strings.HasPrefix(c.Starts[0], "D-Bus activation") {
t.Fatalf("%+v %v", c, err)
}
broken := newStack(t, false, false)
broken.write("/proc/sys/kernel/osrelease", "7.3.0-arch1-1\n")
broken.write(testHome+"/.config/i3/config", "exec --no-startup-id openrazer-daemon\n")
c, _ = broken.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What+" => "+x.Do)
}
got := strings.Join(all, "\n")
for _, want := range []string{"not built for the running kernel 7.3.0-arch1-1", "not in the openrazer group", "gpasswd -a $USER openrazer",
"a second start: ~/.config/i3/config:1", "failed at its last start: razer | CRITICAL | User is not a member"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
}
func TestAGroupAddedAfterLoginIsNamedAsTheServiceManagers(t *testing.T) {
f := newStack(t, true, true)
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t970\n")
c, _ := f.Check()
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "service manager started before") {
t.Fatalf("%+v", c)
}
}
func TestADaemonOutsideItsUnitOrSeeingFewerDevicesIsAFinding(t *testing.T) {
f := newStack(t, true, true)
f.write("/proc/4269/cgroup", "0::/user.slice/user-1000.slice/session-c1.scope\n")
f.write("/sys/bus/hid/drivers/razerkbd/0003:1532:0099.0001/x", "")
c, _ := f.Check()
var all []string
for _, x := range c.Findings {
all = append(all, x.What)
}
got := strings.Join(all, "\n")
if !strings.Contains(got, "outside its unit (in session-c1.scope)") || !strings.Contains(got, "holds 2 device(s) and the daemon sees 1") {
t.Fatalf("%s", got)
}
}
func TestRestartGoesThroughTheUnitAndSaysWhyItFailed(t *testing.T) {
f := newStack(t, true, true)
a, err := f.Restart()
if err != nil || a.Version != "3.12.4" || len(a.Devices) != 1 || !f.called("systemctl --user restart "+daemonUnit) {
t.Fatalf("%+v %v %q", a, err, f.calls)
}
failing := newStack(t, false, false)
inner := failing.answer
failing.answer = func(name string, args []string) Output {
if name == "systemctl" && len(args) > 1 && args[1] == "restart" {
return Output{Code: 1, Stderr: "Job for openrazer-daemon.service failed"}
}
return inner(name, args)
}
if _, err := failing.Restart(); err == nil || !strings.Contains(err.Error(), "not a member of the openrazer group") {
t.Fatalf("%v", err)
}
}