diff --git a/modules/blueman/cmd/blueman-tools/copies_test.go b/modules/blueman/cmd/blueman-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/blueman/cmd/blueman-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/blueman/cmd/blueman-tools/desktop.go b/modules/blueman/cmd/blueman-tools/desktop.go
index 7bc5524..4f52391 100644
--- a/modules/blueman/cmd/blueman-tools/desktop.go
+++ b/modules/blueman/cmd/blueman-tools/desktop.go
@@ -1,8 +1,8 @@
package main
-// desktop.go is the same file in the nextcloud-client, blueman and slack bundles: a
-// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
-// ADR 0208).
+// 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
@@ -252,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
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") {
@@ -412,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// 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 pids []int
+ var ps []Proc
for _, c := range comms {
- for _, p := range m.procs(c) {
- if m.Kill(p.PID, syscall.SIGTERM) == nil {
- pids = append(pids, p.PID)
- }
+ 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 {
@@ -452,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// 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 {
+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.procs(comm); len(p) > 0 || waited >= d {
+ if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
diff --git a/modules/blueman/cmd/blueman-tools/desktop_test.go b/modules/blueman/cmd/blueman-tools/desktop_test.go
index 850996f..e17bb69 100644
--- a/modules/blueman/cmd/blueman-tools/desktop_test.go
+++ b/modules/blueman/cmd/blueman-tools/desktop_test.go
@@ -1,7 +1,7 @@
package main
-// The fake machine the tests run against, and the tests of desktop.go. The same in the
-// nextcloud-client, blueman and slack bundles.
+// 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"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
+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") {
diff --git a/modules/cups/README.md b/modules/cups/README.md
index 6141bbd..927b509 100644
--- a/modules/cups/README.md
+++ b/modules/cups/README.md
@@ -78,6 +78,25 @@ today), remove the unused vendor drivers, once:
- **desktop:** `brother-mfc-l8390cdw` and `brother-mfc-l8390cdw-debug`. Keep `cnijfilter-mg4200` while
the Canon queue is used.
+## The print applet: not this module's
+
+The desktop has `system-config-printer` (official, explicit, installed 2026-10-04). Its package ships
+`/etc/xdg/autostart/print-applet.desktop`, so from the desktop's next login `dex` starts
+`system-config-printer-applet`, a tray icon for print jobs and printer problems. The laptop does not
+have the package.
+
+This module does not take it:
+
+- `cups` needs no display and declares nothing graphical. The applet requires `x11-display`, and a
+ GTK package in this module would put it on any machine that prints, the laptop included, where the
+ operator never installed it.
+- The queues and the default printer are already this module's tools (`cups_printers`, `cups_queue`,
+ `cups_default`), which is most of what the applet's window offers.
+
+So the applet is left as found on the desktop, started by its package's entry. If it is wanted on both
+workstations, it becomes a `system-config-printer` desktop module of its own, beside `blueman` and
+`nm-applet`, requiring `x11-display`. If not, removing the package on the desktop ends it.
+
## Leaves as found
The queues and their PPDs, the default printer, `cups.path` (enabled by the package's preset),
diff --git a/modules/forticlient/README.md b/modules/forticlient/README.md
new file mode 100644
index 0000000..2e7034f
--- /dev/null
+++ b/modules/forticlient/README.md
@@ -0,0 +1,94 @@
+# forticlient
+
+The FortiClient VPN client on the workstations, as a module (novox/hq ADR 0208): its tray in the
+operator's session and the vendor's service behind it. It requires `x11-display`, so it is assigned
+only where a display server is held on the same machine.
+
+**This is the operator's work VPN.** Nothing of its configuration is the mesh's: no profile, no
+credential, no gateway, no certificate is declared, read, printed or stored by the module or its
+tools. The tools report running and connected state only.
+
+## Owns
+
+| what | where |
+|---|---|
+| the vendor's scheduler service, which holds the tunnel | `forticlient.service`, running and enabled |
+
+Nothing else. It holds no seat, makes no contribution and writes no file.
+
+- **The client is kept as found.** `forticlient-vpn` (7.4.3) is not in the official repositories: on
+ both workstations it is a foreign (AUR) package that repackages the vendor's build, installed
+ explicitly. The host installs from the official repositories only, so the module cannot declare it.
+ ADR 0205's pinned archive does not fit: it is a vendor binary set with a root service, a firewall
+ helper and an install script. It waits for the mesh's package repository (research 027 question 1,
+ option P2). Until then a fresh workstation installs it by hand.
+- **The service is declared, and so depended on.** `forticlient.service` is the package's unit, running
+ and enabled on both workstations. Declared running and enabled, it is held in that state, and on a
+ machine without the package the host refuses it by name (*does not exist on this machine*): loud,
+ never a silent pass. The `asus-zephyrus-g14` module does the same with its foreign daemons. The
+ module never restarts it: a change to nothing of the module's would, and nothing of the module's
+ changes.
+- **The configuration stays the operator's, and unread.** `/etc/forticlient/`, the client's database
+ under `/opt/forticlient/`, the account's FortiClient settings, the VPN profiles, saved credentials
+ and certificates are set in the client's own window. They are found (ADR 0182), and unlike any other
+ found file, the tools do not even read them.
+
+## How it starts: the vendor's autostart entry, and nothing else
+
+The tray has two processes: `fortitraylauncher`, which starts and watches `fortitray`. The package's
+install script links `/etc/xdg/autostart/Fortitray.desktop` to the package's
+`/opt/forticlient/Fortitray.desktop` (`Exec=/opt/forticlient/fortitraylauncher`). The session runs it
+once at login through the `i3` module's `dex --autostart --environment i3`. **That entry is the tray's
+one start.** The module adds no `xinitrc` slot and no `node-display-session` exec, because either would
+start it a second time. The link is the vendor's, made by its install script; the mesh does not make
+or remove it.
+
+The tunnel is not the tray's: the service's processes hold it, as root. Ending the tray leaves a
+connected tunnel connected.
+
+## Tools
+
+They are served by the node's runtime as the operator account (ADR 0175).
+
+| tool | does |
+|---|---|
+| `forticlient_status` (r) |
- the installed version, and that it is from outside the official repositories
- the service: active, enabled
- whether the launcher and the tray run: pid, since, and the scope or unit they run in
- what starts the tray at login
- connected or not, as the number of the client's tunnel interfaces that are up
|
+| `forticlient_restart` (a) | asks the tray and its launcher to end (SIGTERM), forces them after 5 s, and starts the launcher in the operator's session as a transient user unit `mesh-forticlient-tray`, so it outlives the tools runtime. The launcher starts the tray. The service and the tunnel are not touched. Refused plainly when nobody is logged in to the desktop |
+| `forticlient_check` (r) | - the package is installed
- the service is running and enabled
- exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed
- no window-manager exec
- one launcher and one tray run in a desktop session
Being connected is never a finding: that is the operator's to decide. Each finding says what to do |
+
+**What the tools never touch**, held by the tests (a fake machine carries a profile, a gateway, an
+address, a secret and a certificate where the client keeps them, and no answer may hold any of them):
+
+- no file under `/etc/forticlient` or the account's FortiClient settings is opened, and under
+ `/opt/forticlient` only the tray's autostart entry, through its link (it names the launcher and
+ nothing else);
+- the vendor's command-line client and `fortivpn` are never run, and its logs are never read;
+- a process is named by its command name only, never by its arguments;
+- *connected* is whether an interface named `fctvpn…` is up, from its flags. The interface's name
+ (it carries an identifier) and its addresses are never answered. A tunnel of a kind that brings up
+ no such interface (IPsec) is not seen, and the answer says *not connected*.
+
+## What changes when it is assigned
+
+| | laptop | desktop |
+|---|---|---|
+| package | none: `forticlient-vpn` 7.4.3.5411, explicit, foreign | the same |
+| service | none: running and enabled | the same |
+| tray | none: dex starts it from the vendor's entry, in the login session's scope | none on disk. **No tray runs now:** that session began before `dex` was installed, and the predecessor's window manager never started it. The next login is the first that starts it |
+
+## Migration (ADR 0182)
+
+Nothing is required on either machine. On the desktop, log out and in once, or run
+`forticlient_restart`, and the tray runs from its one start. `forticlient_check` then answers `ok`.
+
+## Leaves as found
+
+Everything of the client's: its configuration and database, the VPN profiles and credentials, its
+logs, `/etc/xdg/autostart/Fortitray.desktop` (the vendor's link), the package itself.
+
+## Relies on
+
+- **The package, installed by hand.** Without it the host refuses the service by name.
+- **`i3`'s `dex` line for the start**: XDG autostart has no seat. Assigned without `i3`, the tray does
+ not start. `forticlient_check` says so.
+- A display server on the same machine (`x11-display`, ADR 0208 §3).
diff --git a/modules/forticlient/cmd/forticlient-tools/copies_test.go b/modules/forticlient/cmd/forticlient-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/forticlient/cmd/forticlient-tools/desktop.go b/modules/forticlient/cmd/forticlient-tools/desktop.go
new file mode 100644
index 0000000..4f52391
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/desktop.go
@@ -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"`
+}
diff --git a/modules/forticlient/cmd/forticlient-tools/desktop_test.go b/modules/forticlient/cmd/forticlient-tools/desktop_test.go
new file mode 100644
index 0000000..e17bb69
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/desktop_test.go
@@ -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))
+ }
+}
diff --git a/modules/forticlient/cmd/forticlient-tools/forticlient.go b/modules/forticlient/cmd/forticlient-tools/forticlient.go
new file mode 100644
index 0000000..c67b462
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/forticlient.go
@@ -0,0 +1,205 @@
+package main
+
+// FortiClient as the tools see it: the vendor's scheduler service, the tray and the launcher that
+// starts it, the tray's XDG autostart entry, and whether a tunnel is up. This is the operator's work
+// VPN, so the tools never read its profiles, its credentials, its gateways or its certificates: no file
+// under /etc/forticlient or the account's FortiClient settings is opened, and under /opt/forticlient
+// only the tray's autostart entry (through its link in /etc/xdg/autostart); the vendor's command-line
+// client is never run, its logs are never read, a process is named by its command name only (never its
+// arguments), and a tunnel is counted, never named or addressed.
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "time"
+)
+
+const (
+ launcherComm = "fortitraylaunch" // the kernel keeps 15 characters of fortitraylauncher
+ launcherWord = "fortitraylauncher"
+ trayComm = "fortitray"
+ launcherBin = "/opt/forticlient/fortitraylauncher"
+ entryName = "Fortitray.desktop"
+ restartAs = "mesh-forticlient-tray"
+ packageFor = "forticlient-vpn"
+ serviceUnit = "forticlient.service"
+ // tunnelPrefix starts the name of the interface the client brings up for a connected tunnel.
+ tunnelPrefix = "fctvpn"
+)
+
+// named keeps a process's command name and drops its arguments.
+func named(ps []Proc, comm string) []Proc {
+ out := []Proc{}
+ for _, p := range ps {
+ p.Command = comm
+ out = append(out, p)
+ }
+ return out
+}
+
+// Tunnel is whether the client holds a tunnel up: how many of its tunnel interfaces exist and are up.
+// Their names and addresses are never answered.
+type Tunnel struct {
+ Connected bool `json:"connected"`
+ Up int `json:"tunnels_up"`
+}
+
+func (m *Machine) tunnel() Tunnel {
+ t := Tunnel{}
+ entries, _ := os.ReadDir(m.path("/sys/class/net"))
+ for _, e := range entries {
+ if !strings.HasPrefix(e.Name(), tunnelPrefix) {
+ continue
+ }
+ flags, err := strconv.ParseUint(strings.TrimPrefix(readTrimmed(m.path(filepath.Join("/sys/class/net", e.Name(), "flags"))), "0x"), 16, 32)
+ if err == nil && flags&1 == 1 { // IFF_UP
+ t.Up++
+ }
+ }
+ t.Connected = t.Up > 0
+ return t
+}
+
+// Service is the vendor's scheduler, which holds the tunnel.
+type Service struct {
+ Active string `json:"active"`
+ Enabled string `json:"enabled"`
+}
+
+func (m *Machine) service() Service {
+ word := func(verb string) string {
+ o := m.cmd(5*time.Second, nil, "systemctl", verb, serviceUnit)
+ if f := strings.Fields(o.Stdout); o.Err == nil && len(f) > 0 {
+ return f[0]
+ }
+ return "unknown"
+ }
+ return Service{Active: word("is-active"), Enabled: word("is-enabled")}
+}
+
+// foreign says whether the package came from outside the official repositories (pacman -Qm).
+func (m *Machine) foreign() bool {
+ o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
+ return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
+}
+
+// StatusAnswer is what forticlient_status answers.
+type StatusAnswer struct {
+ Installed string `json:"installed,omitempty"`
+ Foreign bool `json:"outside_official_repositories"`
+ Service Service `json:"service"`
+ Launcher []Proc `json:"launcher"`
+ Tray []Proc `json:"tray"`
+ StartedBy Autostart `json:"started_by"`
+ Tunnel Tunnel `json:"tunnel"`
+}
+
+// Status reads the client's running state, and nothing of its configuration.
+func (m *Machine) Status() (StatusAnswer, error) {
+ s := StatusAnswer{Service: m.service(), Launcher: named(m.procs(launcherComm), launcherWord),
+ Tray: named(m.procs(trayComm), trayComm), StartedBy: m.autostart(entryName), Tunnel: m.tunnel()}
+ v, err := m.installed(packageFor)
+ if err != nil {
+ return s, err
+ }
+ s.Installed = v
+ if v != "" {
+ s.Foreign = m.foreign()
+ }
+ return s, nil
+}
+
+// RestartAnswer is what forticlient_restart answers.
+type RestartAnswer struct {
+ Ended []int `json:"ended"`
+ Killed []int `json:"killed,omitempty"`
+ Running []Proc `json:"running"`
+ Session Session `json:"session"`
+ Unit string `json:"unit"`
+ // Tunnel is after the restart: the tray is only the client's face, the service holds the tunnel.
+ Tunnel Tunnel `json:"tunnel"`
+}
+
+// Restart ends the tray and its launcher and starts the launcher again in the operator's session,
+// under the account's service manager. The launcher starts the tray. The tunnel is the service's,
+// and is not touched.
+func (m *Machine) Restart() (RestartAnswer, error) {
+ s, err := m.session()
+ if err != nil {
+ return RestartAnswer{}, err
+ }
+ a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
+ a.Ended, a.Killed = m.stop(5*time.Second, launcherComm, trayComm)
+ if err := m.detach(s, restartAs, launcherBin); err != nil {
+ return a, err
+ }
+ a.Running = named(m.waitFor(launcherComm, 4*time.Second), launcherWord)
+ a.Tunnel = m.tunnel()
+ if len(a.Running) == 0 {
+ return a, fmt.Errorf("the launcher was started as %s but no %s process appeared within 4 s",
+ a.Unit, launcherWord)
+ }
+ return a, nil
+}
+
+// CheckAnswer is what forticlient_check answers. Being connected or not is never a finding: that is
+// the operator's choice, and forticlient_status says which.
+type CheckAnswer struct {
+ OK bool `json:"ok"`
+ Findings []Finding `json:"findings"`
+ Starts []string `json:"starts"`
+}
+
+// Check verifies what the module promises and relies on: the package (found, not installed by the
+// mesh); the service running and enabled; one start for the tray (the vendor's autostart entry, which
+// the session's dex runs); and the tray running once in a desktop session.
+func (m *Machine) Check() (CheckAnswer, error) {
+ a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
+ add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
+ v, err := m.installed(packageFor)
+ if err != nil {
+ return a, err
+ }
+ if v == "" {
+ add("forticlient-vpn is not installed. It is outside the official repositories, so the mesh does not install it",
+ "install it from the AUR by hand")
+ }
+ if s := m.service(); s.Active != "active" || s.Enabled != "enabled" {
+ add(fmt.Sprintf("the service %s is %s and %s", serviceUnit, s.Active, s.Enabled),
+ "push the module, which declares it running and enabled")
+ }
+ entry := m.autostart(entryName)
+ if entry.Starts {
+ a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
+ if entry.From != "/etc/xdg/autostart/"+entryName {
+ add("the account's own "+entry.From+" replaces the vendor's entry", "remove it, so the vendor's entry is the one start")
+ }
+ } else {
+ add("the tray does not start with the session ("+entry.Because+")",
+ "remove ~/.config/autostart/"+entryName+" if it hides the vendor's entry; if the vendor's is gone, reinstall forticlient-vpn, whose install links it")
+ }
+ if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
+ add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
+ }
+ for _, word := range []string{launcherWord, trayComm} {
+ for _, l := range m.i3Starts(word) {
+ a.Starts = append(a.Starts, "window manager: "+l)
+ add("a second start: "+l, "remove the line; the vendor's autostart entry is the tray's one start")
+ }
+ }
+ if _, err := m.session(); err == nil {
+ for _, p := range []struct{ comm, word string }{{launcherComm, launcherWord}, {trayComm, trayComm}} {
+ switch running := m.procs(p.comm); {
+ case len(running) == 0:
+ add("no "+p.word+" runs in the desktop session", "forticlient_restart")
+ case len(running) > 1:
+ add(fmt.Sprintf("%d of %s run", len(running), p.word), "forticlient_restart ends them all and starts one")
+ }
+ }
+ }
+ a.OK = len(a.Findings) == 0
+ return a, nil
+}
diff --git a/modules/forticlient/cmd/forticlient-tools/forticlient_test.go b/modules/forticlient/cmd/forticlient-tools/forticlient_test.go
new file mode 100644
index 0000000..12cacdc
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/forticlient_test.go
@@ -0,0 +1,142 @@
+package main
+
+import (
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+// secrets are what the operator's VPN configuration holds on a real machine. The fake machine carries
+// them where the client keeps them, and no answer may.
+var secrets = []string{"vpn.example.invalid", "192.0.2.10", "s3cret-psk", "work-profile", "BEGIN CERTIFICATE", "65d16a50"}
+
+func newClient(t *testing.T, running, connected bool) *fake {
+ f := newFake(t)
+ f.write("/opt/forticlient/Fortitray.desktop", "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
+ f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nType=Application\nName=Fortitray\nExec=/opt/forticlient/fortitraylauncher\nNoDisplay=true\n")
+ f.write("/etc/forticlient/config.db", "work-profile vpn.example.invalid 192.0.2.10 s3cret-psk\n")
+ f.write(testHome+"/.config/FortiClient/state.json", `{"gateway":"vpn.example.invalid","cert":"-----BEGIN CERTIFICATE-----"}`)
+ f.write("/sys/class/net/wlp3s0/flags", "0x1003\n")
+ if connected {
+ f.write("/sys/class/net/fctvpn65d16a50/flags", "0x1091\n")
+ f.write("/sys/class/net/fctvpn65d16a50/address", "192.0.2.10\n")
+ }
+ if running {
+ f.proc(3857, 1000, launcherComm, []string{launcherBin, "--profile=work-profile"}, "session-c1.scope")
+ f.proc(4509, 1000, trayComm, []string{"/opt/forticlient/fortitray", "--gateway", "vpn.example.invalid"}, "session-c1.scope")
+ }
+ f.answer = func(name string, args []string) Output {
+ switch {
+ case name == "pacman" && args[0] == "-Q":
+ return Output{Stdout: "forticlient-vpn 7.4.3.5411-1\n"}
+ case name == "pacman" && args[0] == "-Qqm":
+ return Output{Stdout: "forticlient-vpn\n"}
+ case name == "systemctl" && args[0] == "is-active":
+ return Output{Stdout: "active\n"}
+ case name == "systemctl" && args[0] == "is-enabled":
+ return Output{Stdout: "enabled\n"}
+ }
+ return Output{}
+ }
+ return f
+}
+
+// carries fails the test when an answer holds anything of the VPN's configuration.
+func carries(t *testing.T, answer any) {
+ t.Helper()
+ raw, _ := json.Marshal(answer)
+ for _, s := range secrets {
+ if strings.Contains(string(raw), s) {
+ t.Fatalf("the answer carries %q: %s", s, raw)
+ }
+ }
+}
+
+func TestStatusIsRunningAndConnectedStateAndNothingOfTheConfiguration(t *testing.T) {
+ f := newClient(t, true, true)
+ s, err := f.Status()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Installed != "7.4.3.5411-1" || !s.Foreign || s.Service != (Service{"active", "enabled"}) || len(s.Launcher) != 1 || len(s.Tray) != 1 ||
+ !s.StartedBy.Starts || !s.Tunnel.Connected || s.Tunnel.Up != 1 {
+ t.Fatalf("%+v", s)
+ }
+ if s.Launcher[0].Command != launcherWord || s.Tray[0].Command != trayComm {
+ t.Fatalf("processes by command name only: %+v %+v", s.Launcher, s.Tray)
+ }
+ carries(t, s)
+ for _, c := range f.calls {
+ for _, never := range []string{"forticlient-cli", "fortivpn", "journalctl", "sqlite", "/etc/forticlient", "/opt/forticlient/.config"} {
+ if strings.Contains(c, never) {
+ t.Fatalf("ran %q", c)
+ }
+ }
+ }
+
+ down := newClient(t, true, false)
+ down.write("/sys/class/net/fctvpn0/flags", "0x1090\n")
+ if s, _ := down.Status(); s.Tunnel.Connected || s.Tunnel.Up != 0 {
+ t.Fatalf("an interface that is down is no tunnel: %+v", s.Tunnel)
+ }
+}
+
+func TestCheckPassesTheVendorsOneStartAndNeverJudgesTheConnection(t *testing.T) {
+ f := newClient(t, true, false)
+ f.desktopSession()
+ c, err := f.Check()
+ if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
+ t.Fatalf("%+v %v", c, err)
+ }
+ carries(t, c)
+
+ f.write(testHome+"/.config/i3/config", "exec --no-startup-id /opt/forticlient/fortitraylauncher\n")
+ f.write(testHome+"/.config/autostart/"+entryName, "[Desktop Entry]\nExec=/opt/forticlient/fortitraylauncher\nHidden=true\n")
+ f.proc(3858, 1000, launcherComm, []string{launcherBin}, "session-c1.scope")
+ f.answer = func(name string, args []string) Output {
+ switch name {
+ case "pacman":
+ return Output{Code: 1}
+ case "systemctl":
+ return Output{Stdout: "inactive\n", Code: 3}
+ case "dex":
+ return Output{Code: 127, Err: ErrNotInstalled}
+ }
+ return Output{}
+ }
+ c, _ = f.Check()
+ var all []string
+ for _, x := range c.Findings {
+ all = append(all, x.What)
+ }
+ got := strings.Join(all, "\n")
+ for _, want := range []string{"not installed", "forticlient.service is inactive", "does not start with the session (Hidden=true)", "dex",
+ "a second start: ~/.config/i3/config:1", "2 of fortitraylauncher run"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("no finding %q in\n%s", want, got)
+ }
+ }
+ carries(t, c)
+}
+
+func TestRestartStartsTheLauncherAndLeavesTheTunnel(t *testing.T) {
+ f := newClient(t, true, true)
+ if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Fatalf("without a desktop: %v", err)
+ }
+ f.desktopSession()
+ f.onStart = func(argv []string) { f.proc(9100, 1000, launcherComm, argv, "app.slice/"+restartAs+".service") }
+ a, err := f.Restart()
+ if err != nil || len(a.Ended) != 2 || len(a.Running) != 1 || !a.Tunnel.Connected {
+ t.Fatalf("%+v %v", a, err)
+ }
+ if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
+ t.Fatalf("%q", f.calls)
+ }
+ for _, c := range f.calls {
+ if strings.Contains(c, serviceUnit) {
+ t.Fatalf("the restart touched the service: %q", c)
+ }
+ }
+ carries(t, a)
+}
diff --git a/modules/forticlient/cmd/forticlient-tools/main.go b/modules/forticlient/cmd/forticlient-tools/main.go
new file mode 100644
index 0000000..3a98fc9
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/main.go
@@ -0,0 +1,49 @@
+// The forticlient module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the FortiClient
+// VPN client's tray in the operator's session and the vendor's service behind it, served by the node's
+// runtime as the operator account. The module holds no seat, so every tool is its own. The tools
+// report running and connected state only: never a profile, a credential, a gateway or a certificate.
+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: "forticlient_status",
+ Description: "The VPN client: the installed version and whether it came from outside the official " +
+ "repositories, the vendor's service (active, enabled), whether the tray and its launcher run " +
+ "(pid, since, scope or unit), what starts the tray at login, and whether a tunnel is up (a " +
+ "count; never a name, an address, a gateway or a profile). (r)",
+ Run: func(map[string]any) (any, error) { return machine.Status() },
+ },
+ {
+ Name: "forticlient_restart",
+ Description: "End the tray and its launcher (asked first, then forced after 5 s) and start the " +
+ "launcher again in the operator's desktop session, under the account's service manager. The " +
+ "tunnel is the service's and is not touched. Needs someone logged in to the desktop. (a)",
+ Run: func(map[string]any) (any, error) { return machine.Restart() },
+ },
+ {
+ Name: "forticlient_check",
+ Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
+ "outside the official repositories); the service runs and is enabled; the tray has exactly one " +
+ "start (the vendor's XDG autostart entry, which the session's dex runs; no window-manager exec); " +
+ "and the tray and its launcher run once in a desktop session. Being connected is not checked. (r)",
+ Run: func(map[string]any) (any, error) { return machine.Check() },
+ },
+ }
+}
diff --git a/modules/forticlient/cmd/forticlient-tools/manifest_test.go b/modules/forticlient/cmd/forticlient-tools/manifest_test.go
new file mode 100644
index 0000000..6979a4f
--- /dev/null
+++ b/modules/forticlient/cmd/forticlient-tools/manifest_test.go
@@ -0,0 +1,106 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// forticlient's shape (novox/hq ADR 0205, ADR 0207, ADR 0208): no package (the client is outside the
+// official repositories and kept as found), the vendor's service declared running and enabled, the X
+// display on its own machine, no start of its own (the vendor's autostart entry is the tray's one
+// start), nothing of the VPN's configuration, and the Go bundle serving exactly the listed forticlient_
+// 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, "forticlient_") || strings.TrimSpace(tool.Description) == "" {
+ t.Errorf("%s: prefixed %s and described", tool.Name, "forticlient_")
+ }
+ }
+ 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/forticlient-tools" || b["binary"] != "forticlient-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 TestItDeclaresTheServiceAndNothingOfTheConfiguration(t *testing.T) {
+ m, raw := readManifest(t)
+ if m.Module != "forticlient" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
+ !reflect.DeepEqual(m.Capabilities, []string{"service-manager"}) {
+ t.Fatalf("%+v", m)
+ }
+ if len(m.Resources) != 1 || m.Resources[0]["type"] != "service" || m.Resources[0]["unit"] != serviceUnit ||
+ m.Resources[0]["state"] != "running" || m.Resources[0]["boot"] != "enabled" || m.Resources[0]["restart-on"] != nil {
+ t.Fatalf("resources: %v", m.Resources)
+ }
+ if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
+ t.Fatal("no seat, no environment, and no second start")
+ }
+ for _, never := range []string{"/etc/forticlient", "/opt/forticlient", "autostart", "\"package\"", "vpn.", "gateway", "profile"} {
+ if strings.Contains(raw, never) {
+ t.Errorf("module.json names %s", never)
+ }
+ }
+}
diff --git a/modules/forticlient/go.mod b/modules/forticlient/go.mod
new file mode 100644
index 0000000..096f86b
--- /dev/null
+++ b/modules/forticlient/go.mod
@@ -0,0 +1,5 @@
+module forticlient
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.7
diff --git a/modules/forticlient/go.sum b/modules/forticlient/go.sum
new file mode 100644
index 0000000..b474419
--- /dev/null
+++ b/modules/forticlient/go.sum
@@ -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=
diff --git a/modules/forticlient/module.json b/modules/forticlient/module.json
new file mode 100644
index 0000000..6ee75f8
--- /dev/null
+++ b/modules/forticlient/module.json
@@ -0,0 +1,39 @@
+{
+ "module": "forticlient",
+ "version": "1",
+ "capabilities": [
+ "service-manager"
+ ],
+ "requires": [
+ "x11-display"
+ ],
+ "tools": [
+ "forticlient_status",
+ "forticlient_restart",
+ "forticlient_check"
+ ],
+ "resources": [
+ {
+ "id": "scheduler",
+ "type": "service",
+ "unit": "forticlient.service",
+ "state": "running",
+ "boot": "enabled"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/forticlient-tools",
+ "binary": "forticlient-tools",
+ "loads": [
+ "forticlient-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/i3/README.md b/modules/i3/README.md
index 5c979c2..b1657c8 100644
--- a/modules/i3/README.md
+++ b/modules/i3/README.md
@@ -91,6 +91,7 @@ file because each module carries them now:
| the wallpaper key (`$mod+Shift+b`) | `feh`'s `50-feh.conf` |
| both bars | `i3status-rust`'s `60-i3status-rust.conf` |
| the keyring prompt (`unlock-keyring.sh`) | gone: `gnome-keyring` unlocks the keyring through PAM at login |
+| the peripherals' tray (`exec … polychromatic-tray-applet`) | `polychromatic`'s contribution to node-display-session |
The theme picker (`$mod+Shift+d`) goes too. It was the predecessor's tool for its theme variables,
and settings take its place once issue 168 closes. `$mod+Delete` (`loginctl lock-session`) stays here,
@@ -98,9 +99,9 @@ because `screen-lock` relies on it. The test `TestTheMainFileAndEveryModulesDrop
loads this file with every catalogue module's drop-in through `i3 -C`, so no two of them bind one
key.
-**Kept until their owners exist.** A marked section holds the peripherals' tray applet and the
-operator's own scripts: volume, games volume, the sessions launcher and the screenshot binding. Each
-leaves when the module that owns it is written.
+**Kept until their owners exist.** A marked section holds the operator's own scripts: volume, games
+volume, the sessions launcher and the screenshot binding. Each leaves when the module that owns it is
+written. The peripherals' tray applet left it for the `polychromatic` module.
## What it leaves found
diff --git a/modules/i3/cmd/i3-tools/manifest_test.go b/modules/i3/cmd/i3-tools/manifest_test.go
index 94d2d3e..d0f8afc 100644
--- a/modules/i3/cmd/i3-tools/manifest_test.go
+++ b/modules/i3/cmd/i3-tools/manifest_test.go
@@ -127,7 +127,9 @@ func TestTheConfigurationIsTheModulesFileImprovedAndEndsWithTheDropIns(t *testin
for _, gone := range []string{"lxpolkit", "xdg-desktop-portal", "xrdb", "Hack Nerd Font", "refresh_i3status", "rice_set", "exec xterm",
"exec --no-startup-id picom", "exec --no-startup-id nm-applet", "exec --no-startup-id blueman-applet", "exec --no-startup-id nextcloud", "hal/",
// carried by their own modules' drop-ins: rofi, clipmenu, feh, i3status-rust, gnome-keyring
- "rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring"} {
+ "rofi", "greenclip", "$mod+period", "powermenu", "theme-picker", ".fehbg", "bar {", "i3status-rs", "unlock-keyring",
+ // the peripherals' tray: polychromatic's contribution
+ "polychromatic"} {
if strings.Contains(code, gone) {
t.Errorf("the configuration still holds %q", gone)
}
diff --git a/modules/i3/config/config b/modules/i3/config/config
index 7840f79..c49398c 100644
--- a/modules/i3/config/config
+++ b/modules/i3/config/config
@@ -174,11 +174,9 @@ client.urgent #900000 #900000 #ffffff #900000 #900000
#########################################
# Each line below belongs to something other than i3, named on its line. When that module is written
# it contributes the line to node-display-session, and the line goes from here in the same change.
-# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute
-# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).
-
-# the peripherals' tray (the operator's application)
-exec --no-startup-id polychromatic-tray-applet
+# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'
+# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,
+# polychromatic).
# the operator's scripts: volume, games volume, sessions, screenshot
bindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up
@@ -194,7 +192,8 @@ bindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/script
###### Other modules' lines ####
#########################################
# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the
-# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.
+# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's
+# under a line naming it.
${contribution:node-display-session:config}
#########################################
###### Your own files ####
diff --git a/modules/i3/module.json b/modules/i3/module.json
index 7257bc0..5dee072 100644
--- a/modules/i3/module.json
+++ b/modules/i3/module.json
@@ -74,7 +74,7 @@
"path": "${machine:account-home}/.config/i3/config",
"owner": "${machine:account}",
"mode": "0644",
- "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR\n# 0212): the mesh places their lines near the end, each module's under a line naming it, where every\n# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read\n# after them, by the include at the very end, and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line to node-display-session, and the line goes from here in the same change.\n# The launcher, the clipboard, the wallpaper, the bars and a machine model's keys already contribute\n# theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14).\n\n# the peripherals' tray (the operator's application)\nexec --no-startup-id polychromatic-tray-applet\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' lines ####\n#########################################\n# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the\n# clipboard, the wallpaper, the bars, a machine model's keys. Each module's under a line naming it.\n${contribution:node-display-session:config}\n#########################################\n###### Your own files ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n"
+ "content": "# i3 config file (v4), written by the mesh (module i3, novox/hq ADR 0208). Replaced at every push;\n# change the module instead. i3's user guide is the reference.\n#\n# Other modules add to this configuration as contributions to node-display-session (novox/hq ADR\n# 0212): the mesh places their lines near the end, each module's under a line naming it, where every\n# variable set here ($mod, $ws1 … $ws10) is in scope. A file of yours in ~/.config/i3/config.d/ is read\n# after them, by the include at the very end, and is yours.\n#\n# The reload watcher of this module reloads i3 when this file or a drop-in changes, after checking the\n# result with i3 -C; it never reloads into a configuration with errors.\n\n# Font for window titles, and the bars below: the mesh's monospace face (research 026/04).\nfont pango:JetBrainsMono Nerd Font 11\n\n# XDG autostart entries (~/.config/autostart, /etc/xdg/autostart), started once at login.\nexec --no-startup-id dex --autostart --environment i3\n\n#########################################\n###### Keys ####\n#########################################\n# To find key symbols: xmodmap -pke / xmodmap -pm\nset $mod Mod4\nset $alt Mod1\nset $shift Shift\nset $ctrl Control\n\n# use these keys for focus, movement, and resize directions when reaching for\n# the arrows is not convenient\nset $left h\nset $down j\nset $up k\nset $right l\n\n# use Mouse+$mod to drag floating windows to their wanted position\nfloating_modifier $mod\n\n# move tiling windows via drag & drop by left-clicking into the title bar,\n# or left-clicking anywhere into the window while holding the floating modifier.\ntiling_drag modifier titlebar\n\n# start a terminal: whichever the terminal module names in $TERMINAL\nbindsym $mod+Return exec i3-sensible-terminal\n\n# kill focused window\nbindsym $mod+$shift+q kill\n\n# change focus\nbindsym $mod+$left focus left\nbindsym $mod+$down focus down\nbindsym $mod+$up focus up\nbindsym $mod+$right focus right\n\nbindsym $mod+Left focus left\nbindsym $mod+Down focus down\nbindsym $mod+Up focus up\nbindsym $mod+Right focus right\n\n# move focused window\nbindsym $mod+$shift+$left move left\nbindsym $mod+$shift+$down move down\nbindsym $mod+$shift+$up move up\nbindsym $mod+$shift+$right move right\n\nbindsym $mod+$shift+Left move left\nbindsym $mod+$shift+Down move down\nbindsym $mod+$shift+Up move up\nbindsym $mod+$shift+Right move right\n\n# split in horizontal orientation\nbindsym $mod+c split h\n# split in vertical orientation\nbindsym $mod+v split v\n\n# enter fullscreen mode for the focused container\nbindsym $mod+f fullscreen toggle\n\n# change container layout (stacked, tabbed, toggle split)\nbindsym $mod+s layout stacking\nbindsym $mod+w layout tabbed\nbindsym $mod+e layout toggle split\n\n# toggle tiling / floating\nbindsym $mod+$shift+space floating toggle\n\n# change focus between tiling / floating windows\nbindsym $mod+space focus mode_toggle\n\n# focus the parent container\nbindsym $mod+a focus parent\n\n# alt-tab functionality\nbindsym $mod+Tab workspace back_and_forth\n\n#########################################\n###### Workspace mgmt ####\n#########################################\nset $ws1 \"1\"\nset $ws2 \"2\"\nset $ws3 \"3\"\nset $ws4 \"4\"\nset $ws5 \"5\"\nset $ws6 \"6\"\nset $ws7 \"7\"\nset $ws8 \"8\"\nset $ws9 \"9\"\nset $ws10 \"10\"\n\nbindsym $mod+1 workspace number $ws1\nbindsym $mod+2 workspace number $ws2\nbindsym $mod+3 workspace number $ws3\nbindsym $mod+4 workspace number $ws4\nbindsym $mod+5 workspace number $ws5\nbindsym $mod+6 workspace number $ws6\nbindsym $mod+7 workspace number $ws7\nbindsym $mod+8 workspace number $ws8\nbindsym $mod+9 workspace number $ws9\nbindsym $mod+0 workspace number $ws10\n\nbindsym $mod+$shift+1 move container to workspace number $ws1\nbindsym $mod+$shift+2 move container to workspace number $ws2\nbindsym $mod+$shift+3 move container to workspace number $ws3\nbindsym $mod+$shift+4 move container to workspace number $ws4\nbindsym $mod+$shift+5 move container to workspace number $ws5\nbindsym $mod+$shift+6 move container to workspace number $ws6\nbindsym $mod+$shift+7 move container to workspace number $ws7\nbindsym $mod+$shift+8 move container to workspace number $ws8\nbindsym $mod+$shift+9 move container to workspace number $ws9\nbindsym $mod+$shift+0 move container to workspace number $ws10\n\n#########################################\n###### Window mgmt ####\n#########################################\nset $resize_px 10 px\nbindsym $mod+$ctrl+$left resize shrink width $resize_px\nbindsym $mod+$ctrl+$down resize grow height $resize_px\nbindsym $mod+$ctrl+$up resize shrink height $resize_px\nbindsym $mod+$ctrl+$right resize grow width $resize_px\n\n#########################################\n###### Session mgmt ####\n#########################################\nbindsym $mod+$shift+e exec \"i3-nagbar -t warning -m 'You pressed the exit shortcut. Do you really want to exit i3? This will end your X session.' -B 'Yes, exit i3' 'i3-msg exit'\"\n\n# Lock the screen through logind, so the one locker the lock screen's module runs handles it (and\n# suspend and lid close too).\nbindsym $mod+Delete exec --no-startup-id loginctl lock-session\n\n# reload the configuration file\nbindsym $mod+$shift+c reload\n\n# restart i3 inplace (preserves your layout/session, can be used to upgrade i3)\nbindsym $mod+$shift+r restart\n\n#########################################\n###### Borders ####\n#########################################\ndefault_border pixel 1\nsmart_borders on\n\n#########################################\n###### Gaps ####\n#########################################\ngaps inner 0\ngaps outer 0\n\n# Only the accent-bearing slots are themed; background and text keep i3's own defaults. Borders are\n# `pixel`, so no title bar shows: the colour says which window has focus.\n# border background text indicator child_border\nclient.focused #de5200 #de5200 #1E2127 #de5200 #de5200\nclient.urgent #900000 #900000 #ffffff #900000 #900000\n\n#########################################\n###### Until their modules carry them ##\n#########################################\n# Each line below belongs to something other than i3, named on its line. When that module is written\n# it contributes the line to node-display-session, and the line goes from here in the same change.\n# The launcher, the clipboard, the wallpaper, the bars, a machine model's keys and the peripherals'\n# tray already contribute theirs (rofi, clipmenu, feh, i3status-rust, asus-zephyrus-g14,\n# polychromatic).\n\n# the operator's scripts: volume, games volume, sessions, screenshot\nbindsym XF86AudioRaiseVolume exec --no-startup-id volume-notify up\nbindsym XF86AudioLowerVolume exec --no-startup-id volume-notify down\nbindsym XF86AudioMute exec --no-startup-id volume-notify mute\nbindsym XF86AudioMicMute exec --no-startup-id mic-notify\nbindsym $ctrl+XF86AudioRaiseVolume exec --no-startup-id set-games-volume 5\nbindsym $ctrl+XF86AudioLowerVolume exec --no-startup-id set-games-volume -5\nbindsym $mod+$shift+Return exec --no-startup-id ~/scripts/i3-sessions/launcher.sh\nbindsym --release $ctrl+$shift+x exec --no-startup-id $XDG_CONFIG_HOME/i3/scripts/screenshot.sh\n\n#########################################\n###### Other modules' lines ####\n#########################################\n# Placed by the mesh from every other module's contribution (novox/hq ADR 0212): the launcher, the\n# clipboard, the wallpaper, the bars, a machine model's keys, the peripherals' tray. Each module's\n# under a line naming it.\n${contribution:node-display-session:config}\n#########################################\n###### Your own files ####\n#########################################\ninclude ~/.config/i3/config.d/*.conf\n"
},
{
"id": "session",
diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
index 7bc5524..4f52391 100644
--- a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
+++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop.go
@@ -1,8 +1,8 @@
package main
-// desktop.go is the same file in the nextcloud-client, blueman and slack bundles: a
-// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
-// ADR 0208).
+// 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
@@ -252,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
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") {
@@ -412,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// 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 pids []int
+ var ps []Proc
for _, c := range comms {
- for _, p := range m.procs(c) {
- if m.Kill(p.PID, syscall.SIGTERM) == nil {
- pids = append(pids, p.PID)
- }
+ 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 {
@@ -452,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// 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 {
+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.procs(comm); len(p) > 0 || waited >= d {
+ if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
diff --git a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
index 850996f..e17bb69 100644
--- a/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
+++ b/modules/nextcloud-client/cmd/nextcloud-client-tools/desktop_test.go
@@ -1,7 +1,7 @@
package main
-// The fake machine the tests run against, and the tests of desktop.go. The same in the
-// nextcloud-client, blueman and slack bundles.
+// 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"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
+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") {
diff --git a/modules/nm-applet/README.md b/modules/nm-applet/README.md
new file mode 100644
index 0000000..a718a54
--- /dev/null
+++ b/modules/nm-applet/README.md
@@ -0,0 +1,78 @@
+# nm-applet
+
+NetworkManager's tray applet on the workstations, as a module (novox/hq ADR 0208). It requires
+`x11-display`, so it is assigned only where a display server is held on the same machine.
+
+## Owns
+
+| what | where |
+|---|---|
+| the applet (and, as its dependencies, the connection editor and libnma) | package `network-manager-applet`, from the official repositories |
+
+Nothing else. It holds no seat, makes no contribution and writes no file.
+
+- **No AUR.** Both workstations run the official package (`extra`), installed explicitly.
+- **NetworkManager is not this module's.** The `networkmanager` module holds the node's uplink
+ (`node-uplink`) with it, on servers as well as workstations. It declares the `networkmanager`
+ package, its one drop-in and its service. This module declares none of them (ADR 0210 §4: one
+ package, one module), and it does not declare the applet's own dependencies (`nm-connection-editor`,
+ `libnma`) either: the package brings them.
+- **Why a module of its own, and not a part of `networkmanager`:** that module runs on machines with no
+ display, where a tray applet has nothing to draw on and the package would pull in GTK. The applet is
+ a desktop piece, beside `blueman` (for `bluetooth`) in the same way.
+- **The connections stay the operator's.** The applet is NetworkManager's secret agent: it asks for a
+ network's password and can keep it in the keyring. Profiles, networks, secrets and addresses are
+ joined at the machine (the `networkmanager` module's README). The tools never ask for them.
+- **The applet's settings are found.** It keeps its switches in dconf (`org.gnome.nm-applet`): the
+ notifications it shows and whether it shows itself. The module neither sets nor resets them.
+
+## How it starts: the package's autostart entry, and nothing else
+
+The package ships `/etc/xdg/autostart/nm-applet.desktop` (`nm-applet`, `NotShowIn=KDE;GNOME;`). The
+session runs it once at login through the `i3` module's `dex --autostart --environment i3`. **That
+entry is the applet's one start.** The module adds no `xinitrc` slot and no `node-display-session`
+exec, because either would start it a second time.
+
+- **Excluded:** the window manager's `exec … nm-applet`, which the `i3` module's configuration dropped.
+
+## Tools
+
+They are served by the node's runtime as the operator account (ADR 0175).
+
+| tool | does |
+|---|---|
+| `nm_applet_status` (r) | - whether the applet runs: pid, since, and the scope or unit it runs in
- the installed version, and what starts it at login
- its switches in `org.gnome.nm-applet`
- NetworkManager's overall state: state, connectivity, Wi-Fi and networking on or off. Never a connection, a network, a secret or an address
|
+| `nm_applet_restart` (a) | asks the applet to end (SIGTERM), forces it after 5 s, and starts `nm-applet` in the operator's session as a transient user unit `mesh-nm-applet`, so it outlives the tools runtime. NetworkManager and its connections are not touched; for those few seconds no secret agent answers a password prompt. Refused plainly when nobody is logged in to the desktop |
+| `nm_applet_check` (r) | - the package is installed
- exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed
- no window-manager exec
- one applet runs in a desktop session
- `NetworkManager.service` is active
Each finding says what to do |
+
+The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment, as
+`blueman` does. 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 |
+|---|---|---|
+| package | none: `network-manager-applet` 1.36.0, explicit, from `extra` | the same |
+| start | none: dex starts the applet from the package's entry, in the login session's scope | none on disk. **The applet running now came from the predecessor's window-manager line** (`nm-applet --sm-disable`, a child of i3, since the session of 2026-10-04 16:00). That session began before the `i3` module dropped the line and installed `dex`, so the next login is the first that starts it from the entry |
+
+## Migration (ADR 0182)
+
+Nothing is required on either machine. On the desktop, log out and in once, or run
+`nm_applet_restart`, and the applet runs from its one start. `nm_applet_check` then answers `ok`.
+
+## Leaves as found
+
+- The applet's dconf settings (`/org/gnome/nm-applet/`).
+- `/etc/xdg/autostart/nm-applet.desktop`, the package's own file.
+- Every connection profile and secret.
+
+## Relies on
+
+- **The `networkmanager` module, for NetworkManager.** There is no dependency mechanism between two
+ modules that hold no seat, so nothing refuses `nm-applet` without it; `nm_applet_check` reports it.
+ `networkmanager` holds `node-uplink`, but a module cannot depend on a seat without a resource or a
+ contribution that derives it (ADR 0207, ADR 0210 §3). **Assign both.**
+- **`i3`'s `dex` line for the start**, which is equally undeclared: XDG autostart has no seat.
+ Assigned without `i3`, the applet is installed and does not start. `nm_applet_check` says so.
+- A display server on the same machine (`x11-display`, ADR 0208 §3).
diff --git a/modules/nm-applet/cmd/nm-applet-tools/copies_test.go b/modules/nm-applet/cmd/nm-applet-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/nm-applet/cmd/nm-applet-tools/desktop.go b/modules/nm-applet/cmd/nm-applet-tools/desktop.go
new file mode 100644
index 0000000..4f52391
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/desktop.go
@@ -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"`
+}
diff --git a/modules/nm-applet/cmd/nm-applet-tools/desktop_test.go b/modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
new file mode 100644
index 0000000..e17bb69
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/desktop_test.go
@@ -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))
+ }
+}
diff --git a/modules/nm-applet/cmd/nm-applet-tools/main.go b/modules/nm-applet/cmd/nm-applet-tools/main.go
new file mode 100644
index 0000000..ea4cc17
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/main.go
@@ -0,0 +1,48 @@
+// The nm-applet module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): NetworkManager's
+// tray applet in the operator's session, served by the node's runtime as the operator account. The
+// module holds no seat, so every tool is its own. NetworkManager itself is the networkmanager 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: "nm_applet_status",
+ Description: "NetworkManager's applet: whether it runs (pid, since, and the unit or session scope it " +
+ "runs in), the installed version, what starts it at login, its notification switches, and " +
+ "NetworkManager's overall state (state, connectivity, Wi-Fi and networking on or off; never a " +
+ "connection, a network, a secret or an address). (r)",
+ Run: func(map[string]any) (any, error) { return machine.Status() },
+ },
+ {
+ Name: "nm_applet_restart",
+ Description: "End the applet (asked first, then forced after 5 s) and start it again in the " +
+ "operator's desktop session, under the account's service manager. NetworkManager and its " +
+ "connections are not touched. Needs someone logged in to the desktop. (a)",
+ Run: func(map[string]any) (any, error) { return machine.Restart() },
+ },
+ {
+ Name: "nm_applet_check",
+ Description: "Check what the module promises and relies on: the package is installed; the applet has " +
+ "exactly one start (the package's XDG autostart entry, which the session's dex runs; no " +
+ "window-manager exec); it runs once in a desktop session; and NetworkManager runs (the " +
+ "networkmanager module's). Answers ok and each finding with what to do. (r)",
+ Run: func(map[string]any) (any, error) { return machine.Check() },
+ },
+ }
+}
diff --git a/modules/nm-applet/cmd/nm-applet-tools/manifest_test.go b/modules/nm-applet/cmd/nm-applet-tools/manifest_test.go
new file mode 100644
index 0000000..3d301f5
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/manifest_test.go
@@ -0,0 +1,104 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// nm-applet's shape (novox/hq ADR 0208, ADR 0210): the applet's one official package, no seat, the X
+// display on its own machine, no start of its own (the package's autostart entry is the one start),
+// nothing of the networkmanager module's (its package, its configuration, its service), and the Go
+// bundle serving exactly the listed nm_applet_ 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, "nm_applet_") || strings.TrimSpace(tool.Description) == "" {
+ t.Errorf("%s: prefixed %s and described", tool.Name, "nm_applet_")
+ }
+ }
+ 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/nm-applet-tools" || b["binary"] != "nm-applet-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 TestItInstallsTheAppletAndNothingOfNetworkManager(t *testing.T) {
+ m, raw := readManifest(t)
+ if m.Module != "nm-applet" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) ||
+ !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
+ t.Fatalf("%+v", m)
+ }
+ if len(m.Resources) != 1 || m.Resources[0]["type"] != "package" || m.Resources[0]["package"] != packageFor {
+ t.Fatalf("resources: %v", m.Resources)
+ }
+ if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
+ t.Fatal("no seat, no environment, and no second start")
+ }
+ for _, never := range []string{"\"networkmanager\"", "nm-connection-editor", "libnma", "/etc/NetworkManager", "autostart", "service"} {
+ if strings.Contains(raw, never) {
+ t.Errorf("module.json names %s: the stack is the networkmanager module's, the start the package's", never)
+ }
+ }
+}
diff --git a/modules/nm-applet/cmd/nm-applet-tools/nmapplet.go b/modules/nm-applet/cmd/nm-applet-tools/nmapplet.go
new file mode 100644
index 0000000..a898852
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/nmapplet.go
@@ -0,0 +1,169 @@
+package main
+
+// NetworkManager's applet as the tools see it: its process, its XDG autostart entry (the package's),
+// its settings in gsettings, and NetworkManager's overall state as nmcli gives it. The applet is also
+// NetworkManager's secret agent, the program that asks for a network's password; the tools never ask
+// NetworkManager for a connection, a secret or an address. Those are joined at the machine (the
+// networkmanager module's README).
+
+import (
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ appletComm = "nm-applet"
+ appletBin = "/usr/bin/nm-applet"
+ entryName = "nm-applet.desktop"
+ restartAs = "mesh-nm-applet"
+ packageFor = "network-manager-applet"
+ stackUnit = "NetworkManager.service"
+ settingsSet = "org.gnome.nm-applet"
+)
+
+// Manager is NetworkManager's overall state: no connection, network or address.
+type Manager struct {
+ State string `json:"state"`
+ Connectivity string `json:"connectivity"`
+ Wifi string `json:"wifi"`
+ Networking string `json:"networking"`
+}
+
+func (m *Machine) manager() (*Manager, error) {
+ args := []string{"-t", "-f", "STATE,CONNECTIVITY,WIFI,NETWORKING", "general"}
+ o := m.cmd(5*time.Second, nil, "nmcli", args...)
+ if err := failed(o, "nmcli", args...); err != nil {
+ return nil, err
+ }
+ f := strings.Split(strings.TrimSpace(o.Stdout), ":")
+ if len(f) != 4 {
+ return nil, fmt.Errorf("nmcli's general state is not four fields: %q", tail(o.Stdout, 200))
+ }
+ return &Manager{State: f[0], Connectivity: f[1], Wifi: f[2], Networking: f[3]}, nil
+}
+
+// settings are the applet's switches in gsettings: the notifications it shows and whether it shows
+// itself.
+func (m *Machine) settings() map[string]string {
+ out := map[string]string{}
+ o := m.cmd(5*time.Second, m.bus(), "gsettings", "list-recursively", settingsSet)
+ if o.Err != nil || o.Code != 0 {
+ return out
+ }
+ for _, l := range strings.Split(o.Stdout, "\n") {
+ f := strings.Fields(l)
+ if len(f) == 3 && f[0] == settingsSet && f[1] != "stamp" {
+ out[f[1]] = f[2]
+ }
+ }
+ return out
+}
+
+// StatusAnswer is what nm_applet_status answers.
+type StatusAnswer struct {
+ Installed string `json:"installed,omitempty"`
+ Applet []Proc `json:"applet"`
+ StartedBy Autostart `json:"started_by"`
+ Settings map[string]string `json:"settings"`
+ Manager *Manager `json:"network_manager,omitempty"`
+ Unanswered string `json:"unanswered,omitempty"`
+}
+
+// Status reads the applet and NetworkManager's overall state.
+func (m *Machine) Status() (StatusAnswer, error) {
+ s := StatusAnswer{Applet: m.procs(appletComm), StartedBy: m.autostart(entryName), Settings: m.settings()}
+ if s.Applet == nil {
+ s.Applet = []Proc{}
+ }
+ v, err := m.installed(packageFor)
+ if err != nil {
+ return s, err
+ }
+ s.Installed = v
+ if s.Manager, err = m.manager(); err != nil {
+ s.Unanswered = err.Error()
+ }
+ return s, nil
+}
+
+// RestartAnswer is what nm_applet_restart answers.
+type RestartAnswer struct {
+ Ended []int `json:"ended"`
+ Killed []int `json:"killed,omitempty"`
+ Running []Proc `json:"running"`
+ Session Session `json:"session"`
+ Unit string `json:"unit"`
+}
+
+// Restart ends the applet and starts it again in the operator's session, under the account's service
+// manager. NetworkManager and its connections are not touched: the applet is only their face.
+func (m *Machine) Restart() (RestartAnswer, error) {
+ s, err := m.session()
+ if err != nil {
+ return RestartAnswer{}, err
+ }
+ a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
+ a.Ended, a.Killed = m.stop(5*time.Second, appletComm)
+ if err := m.detach(s, restartAs, appletBin); err != nil {
+ return a, err
+ }
+ a.Running = m.waitFor(appletComm, 4*time.Second)
+ if len(a.Running) == 0 {
+ return a, fmt.Errorf("the applet was started as %s but no %s process appeared within 4 s: "+
+ "see `journalctl --user -u %s`", a.Unit, appletComm, a.Unit)
+ }
+ return a, nil
+}
+
+// CheckAnswer is what nm_applet_check answers.
+type CheckAnswer struct {
+ OK bool `json:"ok"`
+ Findings []Finding `json:"findings"`
+ Starts []string `json:"starts"`
+}
+
+// Check verifies what the module promises and relies on: the package; one start (the package's
+// autostart entry, which the session's dex runs); the applet running once in a session; and
+// NetworkManager, which is the networkmanager module's, running.
+func (m *Machine) Check() (CheckAnswer, error) {
+ a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
+ add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
+ v, err := m.installed(packageFor)
+ if err != nil {
+ return a, err
+ }
+ if v == "" {
+ add("the package "+packageFor+" is not installed", "push the module to the node")
+ }
+ entry := m.autostart(entryName)
+ if entry.Starts {
+ a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
+ if entry.From != "/etc/xdg/autostart/"+entryName {
+ add("the account's own "+entry.From+" replaces the package's entry", "remove it, so the package's entry is the one start")
+ }
+ } else {
+ add("the applet does not start with the session ("+entry.Because+")", "remove ~/.config/autostart/"+entryName+" if it hides the package's entry")
+ }
+ if o := m.cmd(0, nil, "dex", "--version"); o.Err != nil {
+ add("dex, which runs the XDG autostart entries at login, is not installed", "assign the i3 module, which installs it and runs it")
+ }
+ for _, l := range m.i3Starts(appletComm) {
+ a.Starts = append(a.Starts, "window manager: "+l)
+ add("a second start: "+l, "remove the line; the package's autostart entry is the applet's one start")
+ }
+ if o := m.cmd(0, nil, "systemctl", "is-active", stackUnit); o.Err != nil || strings.TrimSpace(o.Stdout) != "active" {
+ add("NetworkManager ("+stackUnit+") is not running: the applet has nothing to show",
+ "assign the networkmanager module, which holds the node's uplink with it")
+ }
+ if _, err := m.session(); err == nil {
+ switch running := m.procs(appletComm); {
+ case len(running) == 0:
+ add("no applet runs in the desktop session", "nm_applet_restart")
+ case len(running) > 1:
+ add(fmt.Sprintf("%d applets run", len(running)), "nm_applet_restart ends them all and starts one")
+ }
+ }
+ a.OK = len(a.Findings) == 0
+ return a, nil
+}
diff --git a/modules/nm-applet/cmd/nm-applet-tools/nmapplet_test.go b/modules/nm-applet/cmd/nm-applet-tools/nmapplet_test.go
new file mode 100644
index 0000000..3bf11ad
--- /dev/null
+++ b/modules/nm-applet/cmd/nm-applet-tools/nmapplet_test.go
@@ -0,0 +1,107 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func newApplet(t *testing.T, running bool) *fake {
+ f := newFake(t)
+ f.write("/etc/xdg/autostart/"+entryName, "[Desktop Entry]\nName=Network\nExec=nm-applet\nNotShowIn=KDE;GNOME;\n")
+ if running {
+ f.proc(3860, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
+ }
+ f.answer = func(name string, args []string) Output {
+ switch {
+ case name == "pacman":
+ return Output{Stdout: "network-manager-applet 1.36.0-2\n"}
+ case name == "nmcli":
+ return Output{Stdout: "connected:full:enabled:enabled\n"}
+ case name == "gsettings":
+ return Output{Stdout: "org.gnome.nm-applet disable-connected-notifications false\norg.gnome.nm-applet stamp 0\n" +
+ "org.gnome.nm-applet show-applet true\n"}
+ case name == "systemctl":
+ return Output{Stdout: "active\n"}
+ }
+ return Output{}
+ }
+ return f
+}
+
+func TestStatusReadsTheAppletAndOnlyTheManagersOverallState(t *testing.T) {
+ f := newApplet(t, true)
+ s, err := f.Status()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Installed != "1.36.0-2" || len(s.Applet) != 1 || !s.StartedBy.Starts || s.Manager == nil ||
+ *s.Manager != (Manager{"connected", "full", "enabled", "enabled"}) || len(s.Settings) != 2 || s.Settings["show-applet"] != "true" {
+ t.Fatalf("%+v", s)
+ }
+ for _, c := range f.calls {
+ for _, never := range []string{"connection", "device", "secrets", "--show-secrets", " ip"} {
+ if strings.HasPrefix(c, "nmcli") && strings.Contains(c, never) {
+ t.Fatalf("asked NetworkManager for more than its overall state: %q", c)
+ }
+ }
+ }
+ f.answer = func(name string, args []string) Output {
+ if name == "nmcli" {
+ return Output{Code: 8, Stderr: "Error: NetworkManager is not running."}
+ }
+ return Output{Stdout: "x 1\n"}
+ }
+ if s, _ := f.Status(); s.Manager != nil || !strings.Contains(s.Unanswered, "not running") {
+ t.Fatalf("%+v", s)
+ }
+}
+
+func TestCheckPassesThePackagesOneStartAndNamesEveryOther(t *testing.T) {
+ f := newApplet(t, true)
+ f.desktopSession()
+ c, err := f.Check()
+ if err != nil || !c.OK || len(c.Starts) != 1 || c.Starts[0] != "XDG autostart: /etc/xdg/autostart/"+entryName {
+ t.Fatalf("%+v %v", c, err)
+ }
+
+ f.write(testHome+"/.config/i3/config", "exec --no-startup-id nm-applet --sm-disable\n")
+ f.proc(3861, 1000, appletComm, []string{"nm-applet"}, "session-c1.scope")
+ f.answer = func(name string, args []string) Output {
+ switch name {
+ case "pacman":
+ return Output{Code: 1}
+ case "systemctl":
+ return Output{Stdout: "inactive\n", Code: 3}
+ }
+ return Output{}
+ }
+ c, _ = f.Check()
+ var all []string
+ for _, x := range c.Findings {
+ all = append(all, x.What)
+ }
+ got := strings.Join(all, "\n")
+ for _, want := range []string{"not installed", "a second start: ~/.config/i3/config:1", "NetworkManager.service", "2 applets run"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("no finding %q in\n%s", want, got)
+ }
+ }
+}
+
+func TestRestartEndsTheAppletAndStartsItUnderTheServiceManager(t *testing.T) {
+ f := newApplet(t, true)
+ if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Fatalf("without a desktop: %v", err)
+ }
+ f.desktopSession()
+ f.onStart = func(argv []string) { f.proc(9100, 1000, appletComm, argv, "app.slice/"+restartAs+".service") }
+ a, err := f.Restart()
+ if err != nil || len(a.Ended) != 1 || len(a.Running) != 1 || a.Running[0].Command != appletBin {
+ t.Fatalf("%+v %v", a, err)
+ }
+ for _, c := range f.calls {
+ if strings.Contains(c, stackUnit) {
+ t.Fatalf("the restart touched NetworkManager: %q", c)
+ }
+ }
+}
diff --git a/modules/nm-applet/go.mod b/modules/nm-applet/go.mod
new file mode 100644
index 0000000..7495451
--- /dev/null
+++ b/modules/nm-applet/go.mod
@@ -0,0 +1,5 @@
+module nm-applet
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.7
diff --git a/modules/nm-applet/go.sum b/modules/nm-applet/go.sum
new file mode 100644
index 0000000..b474419
--- /dev/null
+++ b/modules/nm-applet/go.sum
@@ -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=
diff --git a/modules/nm-applet/module.json b/modules/nm-applet/module.json
new file mode 100644
index 0000000..1c6764e
--- /dev/null
+++ b/modules/nm-applet/module.json
@@ -0,0 +1,37 @@
+{
+ "module": "nm-applet",
+ "version": "1",
+ "capabilities": [
+ "package-manager"
+ ],
+ "requires": [
+ "x11-display"
+ ],
+ "tools": [
+ "nm_applet_status",
+ "nm_applet_restart",
+ "nm_applet_check"
+ ],
+ "resources": [
+ {
+ "id": "package",
+ "type": "package",
+ "package": "network-manager-applet"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/nm-applet-tools",
+ "binary": "nm-applet-tools",
+ "loads": [
+ "nm-applet-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/openrazer/README.md b/modules/openrazer/README.md
new file mode 100644
index 0000000..eb5ead3
--- /dev/null
+++ b/modules/openrazer/README.md
@@ -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) | - the three packages' versions
- the driver: the running kernel, DKMS's state for it, the modules loaded, the devices bound (USB id, driver, interfaces)
- the group: whether the account is in it, and whether the account's running service manager has it
- the daemon: its unit's active and enabled states, its process, its version, and, when its unit failed, the reason it gave
- each device the daemon sees: name, type, firmware, battery and charging where the device has them. Never its serial
|
+| `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) | - the packages are installed
- the driver is built for the running kernel, and loaded
- a device is bound
- the account is in `openrazer`, and its service manager has the group (a group added after login needs a new login)
- one start: the activation file is present, no window-manager exec
- one daemon runs, from its unit, and answers
- it sees every device the driver holds
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.
diff --git a/modules/openrazer/cmd/openrazer-tools/copies_test.go b/modules/openrazer/cmd/openrazer-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/openrazer/cmd/openrazer-tools/desktop.go b/modules/openrazer/cmd/openrazer-tools/desktop.go
new file mode 100644
index 0000000..4f52391
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/desktop.go
@@ -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"`
+}
diff --git a/modules/openrazer/cmd/openrazer-tools/desktop_test.go b/modules/openrazer/cmd/openrazer-tools/desktop_test.go
new file mode 100644
index 0000000..e17bb69
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/desktop_test.go
@@ -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))
+ }
+}
diff --git a/modules/openrazer/cmd/openrazer-tools/main.go b/modules/openrazer/cmd/openrazer-tools/main.go
new file mode 100644
index 0000000..44b5c64
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/main.go
@@ -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() },
+ },
+ }
+}
diff --git a/modules/openrazer/cmd/openrazer-tools/manifest_test.go b/modules/openrazer/cmd/openrazer-tools/manifest_test.go
new file mode 100644
index 0000000..6e84476
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/manifest_test.go
@@ -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)
+ }
+ }
+}
diff --git a/modules/openrazer/cmd/openrazer-tools/openrazer.go b/modules/openrazer/cmd/openrazer-tools/openrazer.go
new file mode 100644
index 0000000..f6ba17a
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/openrazer.go
@@ -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:]
+}
diff --git a/modules/openrazer/cmd/openrazer-tools/openrazer_test.go b/modules/openrazer/cmd/openrazer-tools/openrazer_test.go
new file mode 100644
index 0000000..52ed119
--- /dev/null
+++ b/modules/openrazer/cmd/openrazer-tools/openrazer_test.go
@@ -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)
+ }
+}
diff --git a/modules/openrazer/go.mod b/modules/openrazer/go.mod
new file mode 100644
index 0000000..7678d96
--- /dev/null
+++ b/modules/openrazer/go.mod
@@ -0,0 +1,5 @@
+module openrazer
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.7
diff --git a/modules/openrazer/go.sum b/modules/openrazer/go.sum
new file mode 100644
index 0000000..b474419
--- /dev/null
+++ b/modules/openrazer/go.sum
@@ -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=
diff --git a/modules/openrazer/module.json b/modules/openrazer/module.json
new file mode 100644
index 0000000..05c1ae4
--- /dev/null
+++ b/modules/openrazer/module.json
@@ -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"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/polychromatic/README.md b/modules/polychromatic/README.md
new file mode 100644
index 0000000..011dba3
--- /dev/null
+++ b/modules/polychromatic/README.md
@@ -0,0 +1,96 @@
+# polychromatic
+
+The Razer peripherals' tray on the workstations, as a module (novox/hq ADR 0208). It requires
+`x11-display`, so it is assigned only where a display server is held on the same machine. The driver
+and the daemon it drives are the `openrazer` module's.
+
+## Owns
+
+| what | where |
+|---|---|
+| the tray's one start | a contribution to `node-display-session` (ADR 0212): `exec --no-startup-id polychromatic-tray-applet`, placed by the `i3` module among the other modules' lines |
+
+Nothing else. It declares no package, holds no seat and writes no file.
+
+- **The application is kept as found.** `polychromatic` (0.9.8) is not in the official repositories:
+ on both workstations it is a foreign (AUR) package, installed explicitly. The host installs from the
+ official repositories only, so the module cannot declare it. ADR 0205's pinned archive does not fit
+ either: it is a Qt application with a helper, a tray and a controller, not a set of plain files. Like
+ `snapd` and the laptop's `triggerhappy`, it waits for the mesh's package repository (research 027
+ question 1, option P2). Until then a fresh workstation installs it by hand, and
+ `polychromatic_check` says when it is missing.
+- **Its dependencies are the `openrazer` module's** (`python-openrazer`, the daemon, the driver). The
+ tray brings them when installed by hand; the module does not declare them twice.
+- **The application's settings stay the operator's.** `~/.config/polychromatic/` (preferences,
+ presets, effects, device states) is the application's own, rewritten by it. The tools read only the
+ tray's two keys of `preferences.json`.
+
+## How it starts: the module's window-manager line, and nothing else
+
+The tray had **two starts** on both workstations:
+
+1. the window-manager line `exec --no-startup-id polychromatic-tray-applet`, in the `i3` module's
+ section *Until their modules carry them*;
+2. the package's XDG autostart entry `polychromatic-autostart.desktop`, which `dex` runs at login. It
+ runs `polychromatic-helper --autostart`, which waits for the daemon, resumes each device's
+ software effect, and **starts the tray too while the application's setting *Start the tray applet
+ when I log on* is ticked**. It is ticked on both.
+
+The tray takes a lock file at its start and stops an earlier instance, so two starts end as one tray,
+in a race. On the laptop both ran at the login of 2026-10-04 16:26, and one tray was left; which
+start it came from is not recorded anywhere. On the desktop only the window-manager line ran (that
+session began before `dex` was installed).
+
+**This module takes over the window-manager line as its contribution**, and the `i3` module drops it
+in the same change. That line is the tray's one start:
+
+- it starts the tray with the session, once (`exec`, so a reload of i3 starts nothing);
+- it is the mesh's, so it is in the composed configuration only where the module is assigned.
+
+**The helper stays**, for its other work: the effects it resumes at login. The package's entry is not
+the module's to hide, and hiding it would need a file in the account's autostart directory. So the
+operator unticks the tray setting once (migration, below), and `polychromatic_check` names the
+helper's tray start as a second start for as long as the setting is ticked. The module never writes
+the setting: the application rewrites its preferences file itself.
+
+## Tools
+
+They are served by the node's runtime as the operator account (ADR 0175).
+
+| tool | does |
+|---|---|
+| `polychromatic_status` (r) | - whether the tray runs: pid, since, and the scope or unit it runs in
- the installed version, and that it is from outside the official repositories
- what starts it: the window-manager lines, and the helper while the setting is on
- the helper's entry, and the tray setting with its delay
- whether the openrazer daemon answers, and how many devices it has (asked with `--auto-start=no`: never starts it)
|
+| `polychromatic_restart` (a) | asks the tray to end (SIGTERM), forces it after 5 s, and starts `polychromatic-tray-applet` in the operator's session as a transient user unit `mesh-polychromatic-tray`, so it outlives the tools runtime. Refused plainly when nobody is logged in to the desktop |
+| `polychromatic_check` (r) | - the package is installed
- exactly one start: one window-manager line; the helper does not start the tray too; no autostart entry of the account for it
- one tray runs in a desktop session
- the openrazer daemon answers
Each finding says what to do |
+
+The kernel keeps 15 characters of a command name, and `polychromatic-tray-applet` and this bundle's
+`polychromatic-tools` share them. The tools match the tray by its full program name, so they never
+count or end themselves.
+
+## What changes when it is assigned
+
+| | laptop | desktop |
+|---|---|---|
+| package | none: `polychromatic` 0.9.8, explicit, foreign | the same |
+| `~/.config/i3/config` | the tray's line moves from the `i3` module's section to this module's contribution: the same line, and i3's reload starts nothing | the same |
+| tray | none: one runs, left by the two starts' race | none: one runs, from the predecessor's window-manager line (a child of i3, since the session of 2026-10-04 16:00, before `dex` ran) |
+
+## Migration (ADR 0182)
+
+On each workstation, once: in Polychromatic, **untick Preferences → Tray → *Start the tray applet
+when I log on***. From the next login the module's line is the tray's one start, and
+`polychromatic_check` answers `ok` once the daemon answers too (the `openrazer` module's migration).
+
+## Leaves as found
+
+- `~/.config/polychromatic/`: preferences, presets, custom effects, device states.
+- `/etc/xdg/autostart/polychromatic-autostart.desktop`, the package's.
+- The package itself, installed by hand.
+
+## Relies on
+
+- **The `openrazer` module** for the daemon and the driver. The tray without them shows no devices.
+ `polychromatic_check` reports it.
+- **The `i3` module** to place the contribution (ADR 0212 §4: the contribution depends on
+ `node-display-session`), and to run `dex` for the helper.
+- A display server on the same machine (`x11-display`, ADR 0208 §3).
diff --git a/modules/polychromatic/cmd/polychromatic-tools/copies_test.go b/modules/polychromatic/cmd/polychromatic-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/polychromatic/cmd/polychromatic-tools/desktop.go b/modules/polychromatic/cmd/polychromatic-tools/desktop.go
new file mode 100644
index 0000000..4f52391
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/desktop.go
@@ -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"`
+}
diff --git a/modules/polychromatic/cmd/polychromatic-tools/desktop_test.go b/modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
new file mode 100644
index 0000000..e17bb69
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/desktop_test.go
@@ -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))
+ }
+}
diff --git a/modules/polychromatic/cmd/polychromatic-tools/main.go b/modules/polychromatic/cmd/polychromatic-tools/main.go
new file mode 100644
index 0000000..eb6b4bf
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/main.go
@@ -0,0 +1,51 @@
+// The polychromatic module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
+// peripherals' tray in the operator's session, served by the node's runtime as the operator account.
+// The module holds no seat, so every tool is its own. The driver and the daemon the tray drives are
+// the openrazer module's tools.
+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: "polychromatic_status",
+ Description: "The Razer peripherals' tray: whether it runs (pid, since, and the unit or session " +
+ "scope it runs in), the installed version and whether it came from outside the official " +
+ "repositories, what starts it at login, the package's login helper and the application's own " +
+ "tray setting, and whether the openrazer daemon behind it answers, with how many devices. " +
+ "Never starts the tray or the daemon. (r)",
+ Run: func(map[string]any) (any, error) { return machine.Status() },
+ },
+ {
+ Name: "polychromatic_restart",
+ Description: "End the tray (asked first, then forced after 5 s) and start it again in the operator's " +
+ "desktop session, under the account's service manager. Answers the pids ended and the new one. " +
+ "Needs someone logged in to the desktop. (a)",
+ Run: func(map[string]any) (any, error) { return machine.Restart() },
+ },
+ {
+ Name: "polychromatic_check",
+ Description: "Check what the module promises and relies on: the package is installed (by hand: it is " +
+ "outside the official repositories); the tray has exactly one start (the module's line in the " +
+ "window manager's configuration; the package's login helper must not start it too); it runs " +
+ "once in a desktop session; and the openrazer daemon answers. Answers ok and each finding with " +
+ "what to do. (r)",
+ Run: func(map[string]any) (any, error) { return machine.Check() },
+ },
+ }
+}
diff --git a/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go b/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go
new file mode 100644
index 0000000..5baaba4
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/manifest_test.go
@@ -0,0 +1,128 @@
+package main
+
+import (
+ "encoding/json"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+// polychromatic's shape (novox/hq ADR 0205, ADR 0208, ADR 0212): no package (the tray is outside the
+// official repositories and kept as found), the X display on its own machine, one contribution to the
+// window manager's configuration that is the tray's one start, nothing of the openrazer module's, and
+// the Go bundle serving exactly the listed polychromatic_ 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, "polychromatic_") || strings.TrimSpace(tool.Description) == "" {
+ t.Errorf("%s: prefixed %s and described", tool.Name, "polychromatic_")
+ }
+ }
+ 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/polychromatic-tools" || b["binary"] != "polychromatic-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 TestItDeclaresNoPackageAndContributesTheTraysOneStart(t *testing.T) {
+ m, raw := readManifest(t)
+ if m.Module != "polychromatic" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
+ t.Fatalf("%+v", m)
+ }
+ if m.Resources != nil || m.Capabilities != nil || m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil {
+ t.Fatal("no package (outside the official repositories), no seat, no environment, no xinitrc start")
+ }
+ if len(m.Contributions) != 1 || m.Contributions[0].Seat != "node-display-session" || m.Contributions[0].Kind != "config" {
+ t.Fatalf("%+v", m.Contributions)
+ }
+ var code []string
+ for _, l := range strings.Split(m.Contributions[0].Content, "\n") {
+ if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
+ code = append(code, l)
+ }
+ }
+ if !reflect.DeepEqual(code, []string{"exec --no-startup-id " + trayWord}) {
+ t.Fatalf("one line, the tray's start with exec (a reload must not start it again): %q", code)
+ }
+ for _, never := range []string{"openrazer", "preferences.json", "autostart\""} {
+ if strings.Contains(raw, never) {
+ t.Errorf("module.json names %s", never)
+ }
+ }
+}
+
+// The window manager's own check parses the line, as the i3 module's catalogue-wide test does with
+// every contribution together.
+func TestTheContributionParsesInI3(t *testing.T) {
+ i3, err := exec.LookPath("i3")
+ if err != nil {
+ t.Skip("no i3 here to check with")
+ }
+ m, _ := readManifest(t)
+ path := filepath.Join(t.TempDir(), "config")
+ os.WriteFile(path, []byte("set $mod Mod4\n"+m.Contributions[0].Content), 0o644)
+ if out, err := exec.Command(i3, "-C", "-c", path).CombinedOutput(); err != nil || strings.Contains(string(out), "ERROR") {
+ t.Fatalf("i3 -C: %v %s", err, out)
+ }
+}
diff --git a/modules/polychromatic/cmd/polychromatic-tools/polychromatic.go b/modules/polychromatic/cmd/polychromatic-tools/polychromatic.go
new file mode 100644
index 0000000..3dd583a
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/polychromatic.go
@@ -0,0 +1,213 @@
+package main
+
+// Polychromatic's tray as the tools see it: its processes, its one start (the module's line in the
+// window manager's configuration), the package's login helper and the one setting of the application's
+// that makes the helper start the tray too, and whether the daemon it drives answers. The daemon is
+// the openrazer module's; it is asked with --auto-start=no, so a question never starts it.
+
+import (
+ "encoding/json"
+ "fmt"
+ "strings"
+ "time"
+)
+
+const (
+ trayComm = "polychromatic-t" // the kernel keeps 15 characters of polychromatic-tray-applet
+ trayWord = "polychromatic-tray-applet"
+ trayBin = "/usr/bin/polychromatic-tray-applet"
+ helperEntry = "polychromatic-autostart.desktop"
+ restartAs = "mesh-polychromatic-tray"
+ packageFor = "polychromatic"
+ prefsFile = ".config/polychromatic/preferences.json"
+ settingName = "Preferences → Tray → \"Start the tray applet when I log on\""
+)
+
+// trays are the tray's processes. Its cut command name is this bundle's own too, so the program is
+// matched by its full name.
+func (m *Machine) trays() []Proc { return m.procsOf(trayComm, trayWord) }
+
+// TraySetting is the application's own setting that makes its login helper start the tray. The
+// module never writes it: the application rewrites its preferences file itself.
+type TraySetting struct {
+ // Autostart is the setting; Polychromatic's default is on.
+ Autostart bool `json:"autostart"`
+ Delay int `json:"autostart_delay_seconds"`
+ From string `json:"from"`
+}
+
+// setting reads the tray keys of the preferences file and nothing else of it.
+func (m *Machine) setting() TraySetting {
+ s := TraySetting{Autostart: true, From: "Polychromatic's default (no preferences file)"}
+ raw, err := readBounded(m.home(prefsFile))
+ if err != nil {
+ return s
+ }
+ var doc struct {
+ Tray struct {
+ Autostart *bool `json:"autostart"`
+ Delay int `json:"autostart_delay"`
+ } `json:"tray"`
+ }
+ if err := json.Unmarshal(raw, &doc); err != nil {
+ s.From = "Polychromatic's default (the preferences file does not parse)"
+ return s
+ }
+ s.From = "~/" + prefsFile
+ if doc.Tray.Autostart != nil {
+ s.Autostart = *doc.Tray.Autostart
+ }
+ s.Delay = doc.Tray.Delay
+ return s
+}
+
+// Backend is whether the openrazer daemon answers, and how many devices it has.
+type Backend struct {
+ Answers bool `json:"answers"`
+ Devices int `json:"devices"`
+ Unanswered string `json:"unanswered,omitempty"`
+}
+
+func (m *Machine) backend() Backend {
+ args := []string{"--user", "--auto-start=no", "--json=short", "call", "org.razer", "/org/razer", "razer.devices", "getDevices"}
+ o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
+ if err := failed(o, "busctl", args...); err != nil {
+ return Backend{Unanswered: "the openrazer daemon does not answer (asked without starting it): " + err.Error()}
+ }
+ var doc struct {
+ Data [][]string `json:"data"`
+ }
+ if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
+ return Backend{Unanswered: "the daemon's device list is not busctl's JSON"}
+ }
+ return Backend{Answers: true, Devices: len(doc.Data[0])}
+}
+
+// foreign says whether the package came from outside the official repositories (pacman -Qm).
+func (m *Machine) foreign() bool {
+ o := m.cmd(0, nil, "pacman", "-Qqm", packageFor)
+ return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == packageFor
+}
+
+// StatusAnswer is what polychromatic_status answers.
+type StatusAnswer struct {
+ Installed string `json:"installed,omitempty"`
+ Foreign bool `json:"outside_official_repositories"`
+ Tray []Proc `json:"tray"`
+ StartedBy []string `json:"started_by"`
+ Helper Autostart `json:"login_helper"`
+ Setting TraySetting `json:"tray_setting"`
+ Backend Backend `json:"backend"`
+}
+
+// Status reads the tray, what starts it, and whether the daemon behind it answers.
+func (m *Machine) Status() (StatusAnswer, error) {
+ s := StatusAnswer{Tray: m.trays(), StartedBy: []string{}, Helper: m.autostart(helperEntry),
+ Setting: m.setting(), Backend: m.backend()}
+ if s.Tray == nil {
+ s.Tray = []Proc{}
+ }
+ v, err := m.installed(packageFor)
+ if err != nil {
+ return s, err
+ }
+ s.Installed = v
+ if v != "" {
+ s.Foreign = m.foreign()
+ }
+ for _, l := range m.i3Starts(trayWord) {
+ s.StartedBy = append(s.StartedBy, "window manager: "+l)
+ }
+ if s.Helper.Starts && s.Setting.Autostart {
+ s.StartedBy = append(s.StartedBy, "the login helper ("+s.Helper.From+"), as the tray setting is on")
+ }
+ return s, nil
+}
+
+// RestartAnswer is what polychromatic_restart answers.
+type RestartAnswer struct {
+ Ended []int `json:"ended"`
+ Killed []int `json:"killed,omitempty"`
+ Running []Proc `json:"running"`
+ Session Session `json:"session"`
+ Unit string `json:"unit"`
+}
+
+// Restart ends the tray and starts it again in the operator's session, under the account's service
+// manager.
+func (m *Machine) Restart() (RestartAnswer, error) {
+ s, err := m.session()
+ if err != nil {
+ return RestartAnswer{}, err
+ }
+ a := RestartAnswer{Session: s, Unit: restartAs + ".service"}
+ a.Ended, a.Killed = m.stopProcs(5*time.Second, m.trays())
+ if err := m.detach(s, restartAs, trayBin); err != nil {
+ return a, err
+ }
+ a.Running = m.waitForOf(trayComm, trayWord, 4*time.Second)
+ if len(a.Running) == 0 {
+ return a, fmt.Errorf("the tray was started as %s but no %s process appeared within 4 s: "+
+ "see `journalctl --user -u %s`", a.Unit, trayWord, a.Unit)
+ }
+ return a, nil
+}
+
+// CheckAnswer is what polychromatic_check answers.
+type CheckAnswer struct {
+ OK bool `json:"ok"`
+ Findings []Finding `json:"findings"`
+ Starts []string `json:"starts"`
+}
+
+// Check verifies what the module promises and relies on: the package (found, not installed by the
+// mesh); one start, the module's window-manager line; the tray running once; and the daemon answering.
+func (m *Machine) Check() (CheckAnswer, error) {
+ a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
+ add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
+ v, err := m.installed(packageFor)
+ if err != nil {
+ return a, err
+ }
+ if v == "" {
+ add("polychromatic is not installed. It is outside the official repositories, so the mesh does not install it",
+ "install it from the AUR by hand; it brings the openrazer packages with it")
+ }
+
+ lines := m.i3Starts(trayWord)
+ for _, l := range lines {
+ a.Starts = append(a.Starts, "window manager: "+l)
+ }
+ switch {
+ case len(lines) == 0:
+ add("the window manager does not start the tray", "push the module: its line in the window manager's configuration is the tray's start")
+ case len(lines) > 1:
+ for _, l := range lines[1:] {
+ add("a second start: "+l, "remove the line; the module's line is the tray's one start")
+ }
+ }
+ helper := m.autostart(helperEntry)
+ if set := m.setting(); helper.Starts && set.Autostart {
+ a.Starts = append(a.Starts, "login helper: "+helper.From+" (the tray setting is on)")
+ add("a second start: the package's login helper starts the tray too, as the tray setting is on ("+set.From+")",
+ "in Polychromatic, untick "+settingName+". The helper keeps its other work: it resumes the devices' effects")
+ }
+ if own := m.autostart(trayWord + ".desktop"); own.Starts {
+ a.Starts = append(a.Starts, "XDG autostart: "+own.From)
+ add("a second start: "+own.From, "remove it; the module's line is the tray's one start")
+ }
+
+ if _, err := m.session(); err == nil {
+ switch running := m.trays(); {
+ case len(running) == 0:
+ add("no tray runs in the desktop session", "polychromatic_restart")
+ case len(running) > 1:
+ add(fmt.Sprintf("%d trays run", len(running)), "polychromatic_restart ends them all and starts one")
+ }
+ }
+ if b := m.backend(); !b.Answers {
+ add(b.Unanswered+": the tray has no devices to show", "assign the openrazer module; openrazer_check says why the daemon is not running")
+ }
+ a.OK = len(a.Findings) == 0
+ return a, nil
+}
diff --git a/modules/polychromatic/cmd/polychromatic-tools/polychromatic_test.go b/modules/polychromatic/cmd/polychromatic-tools/polychromatic_test.go
new file mode 100644
index 0000000..1dc4a0e
--- /dev/null
+++ b/modules/polychromatic/cmd/polychromatic-tools/polychromatic_test.go
@@ -0,0 +1,122 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// newTray is a machine with the tray as the module wants it: the module's line in i3's configuration,
+// the package's helper entry with the tray setting off, one tray running, and the daemon answering.
+func newTray(t *testing.T, running bool) *fake {
+ f := newFake(t)
+ f.write("/etc/xdg/autostart/"+helperEntry, "[Desktop Entry]\nExec=polychromatic-helper --autostart\n")
+ f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# polychromatic\n"+
+ "exec --no-startup-id polychromatic-tray-applet\n")
+ f.write(testHome+"/"+prefsFile, `{"config_version":8,"tray":{"autostart":false,"autostart_delay":0,"mode":0},"editor":{}}`)
+ if running {
+ f.proc(4122, 1000, trayComm, []string{"polychromatic-tray-applet"}, "session-c1.scope")
+ // the tools themselves: the same cut command name
+ f.proc(4200, 1000, trayComm, []string{"/usr/lib/mesh/polychromatic-tools"}, "system.slice/mesh-runtime.service")
+ }
+ f.answer = func(name string, args []string) Output {
+ switch {
+ case name == "pacman" && args[0] == "-Q":
+ return Output{Stdout: "polychromatic 0.9.8-1\n"}
+ case name == "pacman" && args[0] == "-Qqm":
+ return Output{Stdout: "polychromatic\n"}
+ case name == "busctl":
+ return Output{Stdout: `{"type":"as","data":[["PM2148H00000001"]]}`}
+ }
+ return Output{}
+ }
+ return f
+}
+
+func TestStatusSaysWhatStartsTheTrayAndAsksTheDaemonWithoutStartingIt(t *testing.T) {
+ f := newTray(t, true)
+ s, err := f.Status()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if s.Installed != "0.9.8-1" || !s.Foreign || len(s.Tray) != 1 || !s.Backend.Answers || s.Backend.Devices != 1 ||
+ len(s.StartedBy) != 1 || !strings.HasPrefix(s.StartedBy[0], "window manager: ~/.config/i3/config:3") || s.Setting.Autostart {
+ t.Fatalf("%+v", s)
+ }
+ 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)
+ }
+ }
+}
+
+func TestTheTraySettingIsReadAndDefaultsOn(t *testing.T) {
+ f := newTray(t, false)
+ if s := f.setting(); s.Autostart || s.From != "~/"+prefsFile {
+ t.Fatalf("%+v", s)
+ }
+ f.write(testHome+"/"+prefsFile, `{"tray":{"autostart_delay":3}}`)
+ if s := f.setting(); !s.Autostart || s.Delay != 3 {
+ t.Fatalf("%+v", s)
+ }
+ f.write(testHome+"/"+prefsFile, `not json`)
+ if s := f.setting(); !s.Autostart || !strings.Contains(s.From, "does not parse") {
+ t.Fatalf("%+v", s)
+ }
+}
+
+func TestCheckPassesTheModulesOneStartAndNamesTheHelpersAsASecond(t *testing.T) {
+ f := newTray(t, true)
+ f.desktopSession()
+ c, err := f.Check()
+ if err != nil || !c.OK || len(c.Starts) != 1 {
+ t.Fatalf("%+v %v", c, err)
+ }
+
+ f.write(testHome+"/"+prefsFile, `{"tray":{"autostart":true}}`)
+ f.write(testHome+"/.config/i3/config.d/90-x.conf", "exec_always --no-startup-id polychromatic-tray-applet\n")
+ f.proc(4123, 1000, trayComm, []string{"polychromatic-tray-applet"}, "session-c1.scope")
+ f.answer = func(name string, args []string) Output {
+ switch name {
+ case "pacman":
+ return Output{Code: 1}
+ case "busctl":
+ return Output{Code: 1, Stderr: "Call failed: Unit openrazer-daemon.service failed"}
+ }
+ return Output{}
+ }
+ c, _ = f.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 installed", "a second start: ~/.config/i3/config.d/90-x.conf:1", "the package's login helper starts the tray too",
+ "Start the tray applet when I log on", "2 trays run", "openrazer daemon does not answer"} {
+ if !strings.Contains(got, want) {
+ t.Errorf("no finding %q in\n%s", want, got)
+ }
+ }
+
+ none := newTray(t, false)
+ none.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n")
+ c, _ = none.Check()
+ if c.OK || !strings.Contains(c.Findings[0].What, "does not start the tray") {
+ t.Fatalf("%+v", c)
+ }
+}
+
+func TestRestartEndsTheTrayAndStartsItUnderTheServiceManager(t *testing.T) {
+ f := newTray(t, true)
+ if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
+ t.Fatalf("without a desktop: %v", err)
+ }
+ f.desktopSession()
+ f.onStart = func(argv []string) { f.proc(9100, 1000, trayComm, argv, "app.slice/"+restartAs+".service") }
+ a, err := f.Restart()
+ if err != nil || len(a.Ended) != 1 || len(a.Running) != 1 || a.Running[0].PID != 9100 {
+ t.Fatalf("%+v %v", a, err)
+ }
+ if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
+ t.Fatalf("%q", f.calls)
+ }
+}
diff --git a/modules/polychromatic/go.mod b/modules/polychromatic/go.mod
new file mode 100644
index 0000000..6e99c2e
--- /dev/null
+++ b/modules/polychromatic/go.mod
@@ -0,0 +1,5 @@
+module polychromatic
+
+go 1.22
+
+require git.novox.be/novox/mesh-sdk/go v0.1.7
diff --git a/modules/polychromatic/go.sum b/modules/polychromatic/go.sum
new file mode 100644
index 0000000..b474419
--- /dev/null
+++ b/modules/polychromatic/go.sum
@@ -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=
diff --git a/modules/polychromatic/module.json b/modules/polychromatic/module.json
new file mode 100644
index 0000000..eacb0bf
--- /dev/null
+++ b/modules/polychromatic/module.json
@@ -0,0 +1,34 @@
+{
+ "module": "polychromatic",
+ "version": "1",
+ "requires": [
+ "x11-display"
+ ],
+ "tools": [
+ "polychromatic_status",
+ "polychromatic_restart",
+ "polychromatic_check"
+ ],
+ "contributions": [
+ {
+ "seat": "node-display-session",
+ "kind": "config",
+ "content": "# The Razer peripherals' tray (module polychromatic, novox/hq ADR 0208, ADR 0212). Owned by the mesh:\n# replaced at every push. This line is the tray's one start, at the session's start (exec, not\n# exec_always, so a reload starts nothing). The package's own login helper starts it too while the\n# application's setting \"Start the tray applet when I log on\" is ticked; polychromatic_check names\n# that as a second start.\nexec --no-startup-id polychromatic-tray-applet\n"
+ }
+ ],
+ "build": {
+ "artifacts": [
+ {
+ "name": "tools",
+ "kind": "bundle",
+ "language": "go",
+ "system": "arch",
+ "from": "cmd/polychromatic-tools",
+ "binary": "polychromatic-tools",
+ "loads": [
+ "polychromatic-tools"
+ ]
+ }
+ ]
+ }
+}
diff --git a/modules/slack/cmd/slack-tools/copies_test.go b/modules/slack/cmd/slack-tools/copies_test.go
new file mode 100644
index 0000000..5bb7bb3
--- /dev/null
+++ b/modules/slack/cmd/slack-tools/copies_test.go
@@ -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")
+ }
+}
diff --git a/modules/slack/cmd/slack-tools/desktop.go b/modules/slack/cmd/slack-tools/desktop.go
index 7bc5524..4f52391 100644
--- a/modules/slack/cmd/slack-tools/desktop.go
+++ b/modules/slack/cmd/slack-tools/desktop.go
@@ -1,8 +1,8 @@
package main
-// desktop.go is the same file in the nextcloud-client, blueman and slack bundles: a
-// tray application of the operator's graphical session, seen from the node's tool runtime (novox/hq
-// ADR 0208).
+// 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
@@ -252,6 +252,22 @@ func (m *Machine) procs(comm string) []Proc {
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") {
@@ -412,12 +428,19 @@ func (m *Machine) detach(s Session, unit string, argv ...string) error {
// 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 pids []int
+ var ps []Proc
for _, c := range comms {
- for _, p := range m.procs(c) {
- if m.Kill(p.PID, syscall.SIGTERM) == nil {
- pids = append(pids, p.PID)
- }
+ 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 {
@@ -452,10 +475,13 @@ func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []in
}
// 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 {
+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.procs(comm); len(p) > 0 || waited >= d {
+ if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
return p
}
m.Sleep(step)
diff --git a/modules/slack/cmd/slack-tools/desktop_test.go b/modules/slack/cmd/slack-tools/desktop_test.go
index 850996f..e17bb69 100644
--- a/modules/slack/cmd/slack-tools/desktop_test.go
+++ b/modules/slack/cmd/slack-tools/desktop_test.go
@@ -1,7 +1,7 @@
package main
-// The fake machine the tests run against, and the tests of desktop.go. The same in the
-// nextcloud-client, blueman and slack bundles.
+// 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"
@@ -113,6 +113,23 @@ func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
}
}
+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") {
diff --git a/modules/xdg/README.md b/modules/xdg/README.md
index 7f97603..81b018b 100644
--- a/modules/xdg/README.md
+++ b/modules/xdg/README.md
@@ -106,9 +106,15 @@ The person's list keeps every line as found, the dropped ones included. `xdg_che
## Autostart: listed, not owned
An XDG autostart entry belongs to the module of the application it starts, as `picom` does: picom's
-package entry is its one starter. A future `nextcloud-client` module owns `Nextcloud.desktop` (the
-client writes it itself), a blueman module beside `bluetooth` owns blueman's, and Slack's and JetBrains
-Toolbox's entries would be their modules'.
+package entry is its one starter. `nextcloud-client` holds `Nextcloud.desktop` as its start (the client
+writes it itself), and `blueman`, `nm-applet` and `forticlient` hold their packages' entries as theirs.
+`polychromatic`'s helper entry is that module's too, though the tray's start is its window-manager line.
+Slack's and JetBrains Toolbox's entries would be their modules'. Two entries on the desktop have no
+module and are left as found: the print applet's (the `cups` README says why) and geoclue's demo
+agent (`/usr/lib/geoclue-2.0/demos/agent`). geoclue is there as a dependency of an application of the
+operator's, not as a choice; the agent is what answers geoclue's question "may this program know
+where the machine is" outside GNOME, whose own shell answers it there. It is a dependency's piece, so
+it belongs to whoever installs geoclue on purpose, and nobody does yet.
This module only says what is there, and what the session does with it.
The session's starter is `i3`'s `dex --autostart --environment i3`. `xdg_autostart` decides each