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:
@@ -0,0 +1,106 @@
|
||||
# openrazer
|
||||
|
||||
The Razer peripherals' kernel driver and the account's daemon that drives it, on the workstations, as
|
||||
a module (novox/hq ADR 0208: one module per piece of software). The tray that shows the devices is
|
||||
the `polychromatic` module's. This module needs no display: the daemon speaks to the driver and to its
|
||||
clients on the session bus.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the kernel driver's source, built by DKMS for each installed kernel (`razerkbd`, `razermouse`, `razerkraken`, `razeraccessory`) | package `openrazer-driver-dkms` |
|
||||
| the daemon (`org.razer` on the session bus) and its user unit | package `openrazer-daemon` |
|
||||
| the client library every front end speaks to the daemon through | package `python-openrazer` |
|
||||
|
||||
All three from the official repositories (`extra`). On both workstations they are installed today as
|
||||
dependencies of the AUR tray, and become the mesh's here.
|
||||
|
||||
**Why two modules and not one `razer`:** the driver and the daemon are one project, in the official
|
||||
repositories, and any front end uses them. The tray is another project, outside the official
|
||||
repositories. That is the line between `bluetooth` and `blueman` too. A machine can hold the stack
|
||||
without the tray, for a front end of the operator's own or for the daemon's persistence of the
|
||||
devices' lighting and DPI.
|
||||
|
||||
Not this module's:
|
||||
|
||||
- **The kernel headers DKMS builds against** (`linux-headers`). They are the kernel's, and every DKMS
|
||||
driver on a machine needs them (the laptop also builds `nvidia`, the desktop `vboxhost` and `xone`).
|
||||
No module declares them yet. `openrazer_check` says when the driver is not built for the running
|
||||
kernel.
|
||||
- **`dkms` itself**, which the driver package depends on.
|
||||
- **The account's membership of the `openrazer` group** (below).
|
||||
|
||||
## The group: a step for the operator, once
|
||||
|
||||
The driver's udev rules give each device's files to the group `openrazer`, which the driver package
|
||||
creates (sysusers). The daemon refuses to start for an account outside that group: *User is not a
|
||||
member of the openrazer group*.
|
||||
|
||||
**On both workstations the account is not in it today, so the daemon has failed at every start**
|
||||
since openrazer moved from `plugdev` to its own group. The tray runs, and shows no devices. The
|
||||
account is still in `plugdev`, which openrazer no longer uses. Another device's rules may (the laptop
|
||||
has a Logitech receiver rule that does), so it stays.
|
||||
|
||||
The module cannot declare the membership. The host's `user` resource takes groups, additively, but the
|
||||
`zsh` module already declares the operator's account as its `user` resource. A second module declaring
|
||||
the same account is refused at composition, as two owners of one name. Until the mesh can add a group
|
||||
to the account from a second module, this is the operator's step (below), and `openrazer_check` holds
|
||||
it.
|
||||
|
||||
## How it starts: D-Bus activation, and nothing else
|
||||
|
||||
The package installs `org.razer` as a D-Bus service whose `SystemdService` is the user unit
|
||||
`openrazer-daemon.service`. The first client that asks for `org.razer` starts the daemon through that
|
||||
unit: at login, the tray's helper (`polychromatic-helper --autostart`). **That activation is the
|
||||
daemon's one start.** One unit, so it is never two daemons.
|
||||
|
||||
- The unit is **not enabled** on either workstation, and the module does not enable it. Enabling it
|
||||
would start the same unit at login a moment earlier, with nothing gained.
|
||||
- Asked by a tool, the bus is always called with `--auto-start=no`, so asking never starts it.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `openrazer_status` (r) | <ul><li>the three packages' versions</li><li>the driver: the running kernel, DKMS's state for it, the modules loaded, the devices bound (USB id, driver, interfaces)</li><li>the group: whether the account is in it, and whether the account's running service manager has it</li><li>the daemon: its unit's active and enabled states, its process, its version, and, when its unit failed, the reason it gave</li><li>each device the daemon sees: name, type, firmware, battery and charging where the device has them. Never its serial</li></ul> |
|
||||
| `openrazer_restart` (a) | restarts `openrazer-daemon.service` in the account's service manager and answers the devices it then sees. When it fails, the answer carries the daemon's own reason |
|
||||
| `openrazer_check` (r) | <ul><li>the packages are installed</li><li>the driver is built for the running kernel, and loaded</li><li>a device is bound</li><li>the account is in `openrazer`, and its service manager has the group (a group added after login needs a new login)</li><li>one start: the activation file is present, no window-manager exec</li><li>one daemon runs, from its unit, and answers</li><li>it sees every device the driver holds</li></ul>Each finding says what to do |
|
||||
|
||||
Every command has a timeout and capped output. Everything runs through an injected runner and a fake
|
||||
root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| packages | none: the three are installed, 3.12.4, as dependencies of the tray | the same |
|
||||
| driver | none: built for the running kernel, `razermouse` loaded, a Basilisk V3 Pro bound | the same, a Basilisk V2 bound |
|
||||
| daemon | none: the unit stays disabled; it failed at login (not in the group) | the same |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
On each workstation, once:
|
||||
|
||||
1. `sudo gpasswd -a $USER openrazer`
|
||||
2. Log out of every session, or reboot. The account's service manager takes its groups when it
|
||||
starts, and the daemon runs under it.
|
||||
3. `openrazer_check` answers `ok`, and the tray shows the devices.
|
||||
|
||||
`plugdev` stays. Nothing else is required.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The daemon's settings and persistence under `~/.config/openrazer/`, and its log under
|
||||
`~/.local/share/openrazer/`.
|
||||
- `plugdev` and every other group of the account.
|
||||
- The kernel headers and DKMS.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The account in `openrazer`**, by hand (above).
|
||||
- **A client to start the daemon.** At login that is the `polychromatic` module's tray helper.
|
||||
Without a client nothing asks, and nothing needs it to run.
|
||||
- **The kernel headers of every installed kernel**, for DKMS.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module openrazer
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"module": "openrazer",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"tools": [
|
||||
"openrazer_status",
|
||||
"openrazer_restart",
|
||||
"openrazer_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "driver",
|
||||
"type": "package",
|
||||
"package": "openrazer-driver-dkms"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "package",
|
||||
"package": "openrazer-daemon"
|
||||
},
|
||||
{
|
||||
"id": "library",
|
||||
"type": "package",
|
||||
"package": "python-openrazer"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/openrazer-tools",
|
||||
"binary": "openrazer-tools",
|
||||
"loads": [
|
||||
"openrazer-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user