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) | | +| `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) | 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) | | +| `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) | 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) | | +| `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) | 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) | | +| `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) | 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