Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c89eaf9a6 | ||
|
|
964a4fdfbc | ||
|
|
20603b63e6 | ||
|
|
4bf5eef2fd | ||
|
|
4cac44face | ||
|
|
875d2a0554 | ||
|
|
76707bbe0e | ||
|
|
076455ec78 | ||
|
|
f20c4b749b | ||
|
|
69d6b9066f | ||
|
|
2cc27e2a74 | ||
|
|
0581256905 | ||
|
|
082f8de32f | ||
|
|
428f5b8864 | ||
|
|
579d21a209 | ||
|
|
52b81d524c | ||
|
|
b651137d95 | ||
|
|
c42f1ce45b | ||
|
|
6a42bafb1c | ||
|
|
053eba6950 | ||
|
|
45507c3d5c | ||
|
|
63e53f4622 | ||
|
|
ebacf79c91 | ||
|
|
a66a23582f | ||
|
|
db9a5bff0c | ||
|
|
85352c8d6f | ||
|
|
f74e1f0309 | ||
|
|
97b1b2b36b | ||
|
|
d0d5546088 | ||
|
|
2a99b3c3e9 | ||
|
|
ade4901d7f | ||
|
|
f2b5168647 | ||
|
|
75f25fca21 | ||
|
|
1fc10b0323 | ||
|
|
4f3e52b47a |
@@ -1,2 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
|
||||
# Go tool bundles built in place (go build in a module's cmd/<name>-tools) are build output.
|
||||
modules/slack/cmd/slack-tools/slack-tools
|
||||
modules/jetbrains-toolbox/cmd/toolbox-tools/toolbox-tools
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in the nextcloud-client and blueman 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
|
||||
@@ -251,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") {
|
||||
@@ -411,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 {
|
||||
@@ -451,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)
|
||||
|
||||
@@ -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 and blueman 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") {
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-build-agent",
|
||||
"scope": "node"
|
||||
"scope": "node",
|
||||
"serves": ["current", "kill", "pause", "resume"]
|
||||
}
|
||||
],
|
||||
"requires": [
|
||||
|
||||
@@ -800,8 +800,10 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
|
||||
}
|
||||
}
|
||||
}
|
||||
// Compared before and after rather than read from Take: this node's own watch may take the same change
|
||||
// first, and then Take here finds nothing new although this call made it.
|
||||
before, _ := json.Marshal(v.Items())
|
||||
var keys []string
|
||||
changed := false
|
||||
for _, n := range nodes {
|
||||
key := it.Key(n)
|
||||
keys = append(keys, key)
|
||||
@@ -819,7 +821,12 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
|
||||
op = "delete"
|
||||
}
|
||||
item := it
|
||||
changed = v.Take(key, op, &item) || changed
|
||||
v.Take(key, op, &item)
|
||||
}
|
||||
after, _ := json.Marshal(v.Items())
|
||||
here := string(before) != string(after)
|
||||
for _, k := range keys {
|
||||
here = here || v.Applies(k)
|
||||
}
|
||||
verb := map[bool]string{false: "registered", true: "unregistered"}[unregister]
|
||||
answer := map[string]any{verb: it.Kind + " " + it.Name, "keys": keys}
|
||||
@@ -835,15 +842,15 @@ func Register(p Paths, it Item, nodes []string, unregister bool, state ConfigSta
|
||||
answer["offered as"] = "/" + Plugin + ":" + it.Name
|
||||
}
|
||||
}
|
||||
if changed {
|
||||
if here {
|
||||
// Rendered whenever it applies here, so the answer says what this node wrote, whichever of the
|
||||
// watch and this call took the change.
|
||||
rendered, err := RenderNow(p, write)
|
||||
answer["rendered here"] = rendered
|
||||
if err != nil {
|
||||
answer["not written here"] = err.Error() // kept on the bus all the same; the next render tries again
|
||||
}
|
||||
answer["sessions"] = "a new session takes it; a running one at /reload-plugins"
|
||||
} else if v.Applies(keys[0]) || len(keys) > 1 {
|
||||
answer["here"] = "already so"
|
||||
} else {
|
||||
answer["here"] = "not this node: each node it is for takes it from the bus"
|
||||
}
|
||||
|
||||
@@ -455,3 +455,16 @@ func TestOneFileThatCannotBeWrittenDoesNotStopTheOthers(t *testing.T) {
|
||||
t.Fatalf("the other files were not written: %v", w)
|
||||
}
|
||||
}
|
||||
|
||||
// The answer says what this node wrote even when its own watch took the change first.
|
||||
func TestTheAnswerSaysWhatWasWrittenWhenTheWatchWasFirst(t *testing.T) {
|
||||
p, w := node(t, "laptop")
|
||||
state, view := memConfig{}, NewConfigView(p)
|
||||
it := Item{Kind: KindCommand, Name: "c", Scope: ScopeHome, Files: one("c", "x")}
|
||||
first := it
|
||||
view.Take(it.Key("laptop"), "put", &first) // the watch, first
|
||||
answer, err := Register(p, it, nil, false, state, view, writer(w))
|
||||
if err != nil || answer["rendered here"] == nil || answer["here"] != nil {
|
||||
t.Fatalf("%v %v", answer, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
+14
-24
File diff suppressed because one or more lines are too long
+50
-51
@@ -14,6 +14,8 @@ tools below are the module's own.
|
||||
| `socket` | `docker.socket` running, enabled at boot | given back as found when the module goes (ADR 0118) |
|
||||
| `prune-service`, `prune-timer` | `/etc/systemd/system/docker-prune.{service,timer}`, written whole | removed with the module |
|
||||
| `prune` | `docker-prune.timer` running, enabled at boot; restarted when either file changes | stopped and disabled with the module (the mesh made the unit) |
|
||||
| `daemon` | `live-restore` and the mesh's registry in `insecure-registries` written into `/etc/docker/daemon.json`, beside other keys | each key given back as found when the module goes; only the member this module added leaves the list |
|
||||
| `runtime` | `docker.service` running, enabled at boot; reloaded, never restarted, when `daemon` changes | given back as found (ADR 0118) |
|
||||
|
||||
The weekly prune takes **dangling images and build cache unused for a week, and nothing else**. It
|
||||
takes no volume, no container and no image a container uses, so it never touches a container the mesh
|
||||
@@ -24,62 +26,60 @@ while the machine was off happens at the next boot.
|
||||
`container-runtime`: under ADR 0165, which is still proposed, that word means a running daemon, and
|
||||
the module that installs the daemon cannot require it.
|
||||
|
||||
## The runtime's own file and service (issue 190, hq ADR 0196, ADR 0222)
|
||||
|
||||
This module writes two keys into `/etc/docker/daemon.json` (`into: json`, ADR 0102): `live-restore`
|
||||
and `insecure-registries`. `dnsmasq` used to write `live-restore`, beside `dns`; it no longer writes
|
||||
either. Under ADR 0196 a container copies its machine's resolvers, so no module writes `dns`. The
|
||||
controller's private network wrote `insecure-registries`; under ADR 0222 the controller writes
|
||||
nothing into this file, and this module states the registry itself (the order below).
|
||||
|
||||
- `daemon`: `{"live-restore": true, "insecure-registries": ["${seat:mesh-artifact-store:reach}"]}`,
|
||||
merged into the file beside the keys others write.
|
||||
- `${seat:mesh-artifact-store:reach}` is where this machine reaches the mesh's artifact store
|
||||
(host:port), filled in by the controller: no binding, no credential, the same address the mesh
|
||||
composes into every image it built. Trusting it in the clear is ADR 0082's decision: every path to
|
||||
it is inside the private network's encryption. While no machine on the network holds the store the
|
||||
answer is empty, and the controller drops the empty member, so the list gets nothing.
|
||||
- `insecure-registries` is a list, and the host adds to it rather than replacing it: a machine's own
|
||||
trusted registries stay, and undeclaring takes out only the member this module added.
|
||||
- `runtime`: `docker.service` running, enabled at boot, and **reloaded, never restarted**, when
|
||||
`daemon` changes. A restart stops every container. A reload turns `live-restore` on and takes the
|
||||
trusted registries, and with `live-restore` on a later restart keeps every container running.
|
||||
|
||||
In the apply that moves the key, the host first gives back `dnsmasq`'s resources, then applies this
|
||||
module's: `live-restore` is set again in the same apply, and the daemon is reloaded once.
|
||||
|
||||
`dns` is removed from the file then, but the daemon reads it only at its next start, and a running
|
||||
container keeps the resolvers it was created with. Each container pinned to a machine's own resolver
|
||||
is restarted before that machine's `dnsmasq` goes (ADR 0194, step 4).
|
||||
|
||||
**The order it lands in.** The controller that fills `${seat:…:reach}` is deployed first: one that
|
||||
does not know the placeholder would send it through unfilled. Then this module. Then the controller
|
||||
stops generating the private network's `registry-trust` and `registry-trust-reload` and refuses a
|
||||
generated resource that collides with a module's (issue 190, steps 2 and 5). In the apply that moves
|
||||
the member, the host removes the private network's record first (the member leaves the list) and
|
||||
then applies this module's (it is added back, recorded as this module's); the daemon is reloaded
|
||||
once, for `daemon`. The address is the same one, so the runtime's trust does not change.
|
||||
|
||||
Still elsewhere:
|
||||
|
||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand; they are left
|
||||
as they are until a size is chosen for every machine.
|
||||
|
||||
## What it does not declare yet, and why
|
||||
|
||||
Three things this module should own are already declared by other modules on every machine. The
|
||||
controller refuses two modules on one node that declare the same `path`, `unit`, `name` or `package`
|
||||
(`checkResources`, mesh-controller `internal/catalogue/resolve.go`). Declaring any of them here would
|
||||
make the module unassignable everywhere. The refusals were checked against the controller's own
|
||||
One thing this module should own is still declared elsewhere. The controller refuses two modules
|
||||
on one node that declare the same `path`, `unit`, `name` or `package` (`checkResources`,
|
||||
mesh-controller `internal/catalogue/resolve.go`), so declaring it here would make the module
|
||||
unassignable everywhere. The refusals were checked against the controller's own
|
||||
check:
|
||||
|
||||
```
|
||||
zsh and docker both declare the name "${machine:account}"
|
||||
dnsmasq and docker both declare the path "/etc/docker/daemon.json"
|
||||
dnsmasq and docker both declare the unit "docker.service"
|
||||
```
|
||||
|
||||
### 1. `/etc/docker/daemon.json` and `docker.service` (issue 190)
|
||||
|
||||
Today the file has three writers. Each writes into it (`into: json`, ADR 0102) and reloads the
|
||||
service:
|
||||
|
||||
- **`dnsmasq`** writes `dns` and `live-restore`, through `dnsmasq.runtime-dns` and `dnsmasq.runtime`.
|
||||
- **The private network**, generated by the controller (`internal/overlay/generator.go`), writes
|
||||
`insecure-registries`. The collision check does not see generated resources.
|
||||
- **Nobody** writes log rotation. One machine has `log-driver` and `log-opts` by hand.
|
||||
|
||||
**The change proposed, in one merge:**
|
||||
|
||||
1. `dnsmasq` drops its `runtime-dns` and `runtime` resources.
|
||||
2. `docker` adds the two resources below:
|
||||
|
||||
```json
|
||||
{"id": "daemon", "type": "file", "path": "/etc/docker/daemon.json", "mode": "0644", "into": "json",
|
||||
"content": "{\"dns\": [\"${machine:address}\"], \"live-restore\": true, \"log-driver\": \"json-file\", \"log-opts\": {\"max-size\": \"100m\", \"max-file\": \"5\"}}\n"},
|
||||
{"id": "runtime", "type": "service", "unit": "docker.service", "state": "running", "boot": "enabled", "reload-on": ["daemon"]}
|
||||
```
|
||||
|
||||
The service is **reloaded, never restarted**: a restart stops every container. The daemon reads
|
||||
`live-restore` on a reload. It reads `dns`, `log-driver` and `log-opts` only at its next start, so
|
||||
they apply then (to containers created afterwards, for the log keys). With `live-restore` on, that
|
||||
start keeps every container running.
|
||||
|
||||
**Why one merge, and only after this module is on every machine:**
|
||||
|
||||
- In one apply, the host first gives back the resources that are no longer declared, then applies
|
||||
the new ones (mesh-host `apply.go`).
|
||||
- `dnsmasq` gives back `dns` and `live-restore` to what they held before it, and `docker` sets them
|
||||
again in the same apply. The daemon is reloaded once, after both steps.
|
||||
- A machine pushed the new `dnsmasq` *without* this module would keep its pre-mesh values for both
|
||||
keys. On one machine that is `live-restore: false`, and the next daemon restart there would stop
|
||||
every container.
|
||||
|
||||
**Later:** the controller hands the registry to this module as a value, and the overlay stops
|
||||
generating its two resources (issue 190, steps 2 and 5). Until then the overlay keeps writing its one
|
||||
key beside this module's. The host merges disjoint keys correctly; the mesh-host `into.go` record is
|
||||
per resource.
|
||||
|
||||
### 2. The operator account's membership of the `docker` group
|
||||
### The operator account's membership of the `docker` group
|
||||
|
||||
The right shape is the host's `user` shape. Its `groups` are additive: the host runs
|
||||
`usermod --append` and never takes a group away.
|
||||
@@ -111,9 +111,8 @@ On the machine the mesh was first installed on, the foundation bundle declared `
|
||||
never sees them (mesh-host `store.go`).
|
||||
- So `docker.package` here is a **second record of the same package**. The apply says "already
|
||||
installed", and neither record ever uninstalls it.
|
||||
- This module does not declare `docker.service` today, so nothing overlaps there. The proposed step
|
||||
1 would add a second record of that unit. Its found state is *running*, because genesis started
|
||||
it, so undeclaring this module would leave the daemon running.
|
||||
- `docker.runtime` is likewise a second record of `docker.service`. Its found state is *running*,
|
||||
because genesis started it, so undeclaring this module leaves the daemon running.
|
||||
|
||||
## Tools
|
||||
|
||||
|
||||
@@ -50,6 +50,24 @@
|
||||
"state": "running",
|
||||
"boot": "enabled"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "file",
|
||||
"path": "/etc/docker/daemon.json",
|
||||
"mode": "0644",
|
||||
"into": "json",
|
||||
"content": "{\"live-restore\": true, \"insecure-registries\": [\"${seat:mesh-artifact-store:reach}\"]}\n"
|
||||
},
|
||||
{
|
||||
"id": "runtime",
|
||||
"type": "service",
|
||||
"unit": "docker.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"reload-on": [
|
||||
"daemon"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "prune-service",
|
||||
"type": "file",
|
||||
|
||||
@@ -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) | <ul><li>the installed version, and that it is from outside the official repositories</li><li>the service: active, enabled</li><li>whether the launcher and the tray run: pid, since, and the scope or unit they run in</li><li>what starts the tray at login</li><li>connected or not, as the number of the client's tunnel interfaces that are up</li></ul> |
|
||||
| `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) | <ul><li>the package is installed</li><li>the service is running and enabled</li><li>exactly one start: the vendor's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one launcher and one tray run in a desktop session</li></ul>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).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module forticlient
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
+29
-4
@@ -17,6 +17,8 @@ export interface GiteaRepo {
|
||||
description?: string;
|
||||
html_url: string;
|
||||
default_branch?: string;
|
||||
/** When anything last moved in it — a push, and so a merge. */
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
/** An issue, with its labels flattened to names. */
|
||||
@@ -249,10 +251,24 @@ export class GiteaClient {
|
||||
* `limit` is what is asked for, and a merge that changed more says so rather than being read
|
||||
* page by page: what the mesh does with a partial list is treat the whole repository as changed,
|
||||
* so more pages would buy nothing. */
|
||||
async listPullFiles(owner: string, repo: string, index: number, limit = 100): Promise<{ paths: string[]; truncated: boolean }> {
|
||||
const files = await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=${limit}`);
|
||||
const paths = (files ?? []).map((f) => String(f?.filename ?? "")).filter((p) => p !== "");
|
||||
return { paths, truncated: paths.length >= limit };
|
||||
/** Every file a pull request changed, page by page. **The forge caps a page below what is asked**
|
||||
* (fifty, asked for a hundred), so one page read as the whole list dropped files silently, and a module
|
||||
* whose own files moved was not rebuilt (novox/hq issue 252). Read until a page comes back short; past
|
||||
* `most` files the list is cut and says so, and the mesh then rebuilds everything built from the
|
||||
* repository, the safe direction. */
|
||||
async listPullFiles(owner: string, repo: string, index: number, most = 3000): Promise<{ paths: string[]; truncated: boolean }> {
|
||||
const paths: string[] = [];
|
||||
let pageSize = 0;
|
||||
for (let page = 1; ; page++) {
|
||||
const files = (await this.request<any[]>(`/repos/${owner}/${repo}/pulls/${index}/files?limit=50&page=${page}`)) ?? [];
|
||||
if (page === 1) pageSize = files.length;
|
||||
for (const f of files) {
|
||||
const name = String(f?.filename ?? "");
|
||||
if (name !== "") paths.push(name);
|
||||
}
|
||||
if (files.length === 0 || files.length < pageSize) return { paths, truncated: false };
|
||||
if (paths.length >= most) return { paths, truncated: true };
|
||||
}
|
||||
}
|
||||
|
||||
async createPullRequest(
|
||||
@@ -342,6 +358,7 @@ export class GiteaClient {
|
||||
description: r.description || undefined,
|
||||
html_url: r.html_url,
|
||||
default_branch: r.default_branch,
|
||||
updated_at: r.updated_at ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -584,3 +601,11 @@ export class GiteaAdmin {
|
||||
GiteaAdmin.fail(`/admin/users/${username}`, res);
|
||||
}
|
||||
}
|
||||
|
||||
/** The repositories that moved at or after a moment: every one when there is no moment yet, and one whose
|
||||
* update time is not known, so a forge that does not say is asked as before (novox/hq issue 250). */
|
||||
export function movedSince(repos: GiteaRepo[], floor: string): GiteaRepo[] {
|
||||
if (!floor) return repos;
|
||||
const at = Date.parse(floor);
|
||||
return repos.filter((r) => !r.updated_at || !(Date.parse(r.updated_at) < at));
|
||||
}
|
||||
|
||||
+21
-9
@@ -3,18 +3,18 @@
|
||||
//
|
||||
// Emits (novox/hq ADR 0041/0042):
|
||||
// module.gitea.repo.created — a repository appeared, however it was made (push, web UI, or tool)
|
||||
// module.gitea.pull.merged — a pull request was merged, however it was merged (web UI, API, or tool)
|
||||
//
|
||||
// issue.opened and pull.merged are emitted from the tools (tools/index.ts), at the instant the mesh
|
||||
// takes that action — the natural point, and one process only. repo.created belongs here instead:
|
||||
// a repository is usually born from a `git push` or the web UI, which no tool sees, so polling the
|
||||
// repo list is the only way to catch every path — and keeping it out of the create-repo tool means
|
||||
// the fact is never announced twice from two processes.
|
||||
// issue.opened is emitted from its tool (tools/index.ts). repo.created and pull.merged belong here: a
|
||||
// repository or a merge is as often made by the web UI or a plain API call, which no tool sees, so
|
||||
// polling is the only way to catch every path — and the only emitter, so a fact is never announced
|
||||
// twice. The merge tool announced too until novox/hq issue 250, and every merge it made was heard twice.
|
||||
//
|
||||
// The polling is deliberately unhurried: an event a minute late is still an event, whereas hammering
|
||||
// the forge for an immediacy nobody asked for is not.
|
||||
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
import { GiteaClient } from "./client.js";
|
||||
import { GiteaClient, movedSince } from "./client.js";
|
||||
|
||||
// Without a way to a token — configured, or mintable with the admin account (token.ts) — there is
|
||||
// nothing to watch; log and stay quiet rather than crash the runtime. With one, the first poll mints
|
||||
@@ -84,8 +84,16 @@ function keepAnnounced(): void {
|
||||
writeFileSync(tmp, JSON.stringify({ announced: [...announced].slice(-2000), since }));
|
||||
renameSync(tmp, mergedRecord);
|
||||
}
|
||||
// **Only the repositories that moved** (novox/hq issue 250). A merge is a push, and a push moves the
|
||||
// repository's update time; asking every repository for its pull requests on every tick took longer than the
|
||||
// tick itself, so ticks piled up and a merge was announced minutes late. A repository unchanged since a
|
||||
// minute before the last look is skipped — the minute absorbs the forge's clock against this one.
|
||||
let lastLook = "";
|
||||
const MARGIN_MS = 60_000;
|
||||
async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
const repos = await client.listAllRepos();
|
||||
const began = new Date().toISOString();
|
||||
const floor = lastLook && primedMerges ? new Date(Date.parse(lastLook) - MARGIN_MS).toISOString() : "";
|
||||
const repos = movedSince(await client.listAllRepos(), floor);
|
||||
let changed = false;
|
||||
for (const repo of repos) {
|
||||
const pulls = await client.listPullRequests(repo.owner, repo.name, { state: "closed", sort: "recentupdate", limit: "20" });
|
||||
@@ -124,6 +132,7 @@ async function pollMerged(client: GiteaClient): Promise<void> {
|
||||
if (!primedMerges) since = new Date().toISOString();
|
||||
if (!primedMerges || changed) keepAnnounced();
|
||||
primedMerges = true;
|
||||
lastLook = began;
|
||||
}
|
||||
|
||||
if (gitea) {
|
||||
@@ -132,6 +141,9 @@ if (gitea) {
|
||||
// yet, the admin account refused on a restored forge) is one fact, and a recovery is worth a line.
|
||||
let failing: string | null = null;
|
||||
const tick = (fn: () => Promise<void>, everyMs: number): void => {
|
||||
// **One pass at a time** (novox/hq issue 250): the next pass is scheduled when this one has ended, so a
|
||||
// pass that outlasts its interval delays the next instead of running beside it — two passes at once
|
||||
// could each announce the same merge before either recorded it.
|
||||
const run = (): void =>
|
||||
void fn()
|
||||
.then(() => {
|
||||
@@ -142,8 +154,8 @@ if (gitea) {
|
||||
const why = err instanceof Error ? err.message : String(err);
|
||||
if (why !== failing) console.error(`[gitea] not watching until this clears — ${why}`);
|
||||
failing = why;
|
||||
});
|
||||
setInterval(run, everyMs);
|
||||
})
|
||||
.finally(() => setTimeout(run, everyMs));
|
||||
run();
|
||||
};
|
||||
tick(() => pollRepos(client), 60_000);
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { createServer } from "node:http";
|
||||
import { GiteaClient } from "../client.ts";
|
||||
|
||||
test("every page of a pull request's files is read, though the forge caps a page at fifty", async () => {
|
||||
const total = 59;
|
||||
const server = createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "", "http://x");
|
||||
const page = Number(url.searchParams.get("page") ?? "1");
|
||||
const start = (page - 1) * 50;
|
||||
const files = Array.from({ length: Math.max(0, Math.min(50, total - start)) }, (_, i) => ({ filename: `modules/m${start + i}/x` }));
|
||||
res.setHeader("content-type", "application/json");
|
||||
res.end(JSON.stringify(files));
|
||||
});
|
||||
await new Promise<void>((r) => server.listen(0, r));
|
||||
const port = (server.address() as any).port;
|
||||
const client = new GiteaClient(`http://127.0.0.1:${port}`, "t");
|
||||
const got = await client.listPullFiles("novox", "mesh-catalog", 60);
|
||||
server.close();
|
||||
assert.equal(got.paths.length, total);
|
||||
assert.equal(got.truncated, false);
|
||||
assert.equal(new Set(got.paths).size, total);
|
||||
});
|
||||
|
||||
test("a pass asks only the repositories that moved since the last look, every one before the first", async () => {
|
||||
const { movedSince } = await import("../client.ts");
|
||||
const repos = [
|
||||
{ full_name: "a/old", name: "old", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T10:00:00Z" },
|
||||
{ full_name: "a/new", name: "new", owner: "a", private: false, html_url: "", updated_at: "2026-10-05T16:10:02Z" },
|
||||
{ full_name: "a/unknown", name: "unknown", owner: "a", private: false, html_url: "" },
|
||||
];
|
||||
assert.deepEqual(movedSince(repos, "").map((r) => r.name), ["old", "new", "unknown"]);
|
||||
assert.deepEqual(movedSince(repos, "2026-10-05T16:09:00.000Z").map((r) => r.name), ["new", "unknown"]);
|
||||
assert.deepEqual(movedSince(repos, "2026-10-05T18:10:02+02:00").map((r) => r.name), ["new", "unknown"], "an offset is a moment, not a string");
|
||||
});
|
||||
@@ -1,12 +1,12 @@
|
||||
// gitea's tools — moved here from the shared sdk (novox/hq ADR 0039), importing gitea's own client.
|
||||
// They return structured data; the mesh serves them through the sdk's tool harness.
|
||||
//
|
||||
// Two tools emit an event at the natural point of the action they take (novox/hq ADR 0041/0042):
|
||||
// create-issue emits issue.opened, merge-pull-request emits pull.merged — the mesh's own hand on
|
||||
// the forge, announced the instant it moves. repo.created is deliberately NOT emitted here: repos
|
||||
// are far more often born from a `git push` or the web UI than from this tool, so the events
|
||||
// entrypoint (index.ts) owns that one by polling, which catches every path without this tool and
|
||||
// the poll double-announcing the same repo from two processes.
|
||||
// One tool emits an event at the natural point of the action it takes (novox/hq ADR 0041/0042):
|
||||
// create-issue emits issue.opened. pull.merged and repo.created are deliberately NOT emitted here: a
|
||||
// merge or a repository is as often made in the web UI or by a plain API call as by these tools, so the
|
||||
// events entrypoint (index.ts) owns both by polling, which catches every path. Announcing a merge here
|
||||
// as well announced every merge made through this tool twice — the tool's at once, the poll's moments
|
||||
// later (novox/hq issue 250).
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { emit } from "@novox/mesh-sdk/events";
|
||||
@@ -228,29 +228,11 @@ export function getGiteaTools(gitea: GiteaClient): ToolDefinition[] {
|
||||
const number = Number(args.number);
|
||||
const method = args.method ? String(args.method) : "merge";
|
||||
const deleteBranch = args.delete_branch === undefined ? true : Boolean(args.delete_branch);
|
||||
// Read the PR first, so the merged event carries a title and branches, not just a number.
|
||||
const pull = await gitea.getPullRequest(owner, repo, number);
|
||||
await gitea.mergePullRequest(owner, repo, number, method, deleteBranch);
|
||||
// Read it again: the merge commit only exists now, and it is what a build is made from.
|
||||
// The merge commit only exists now; answered so the caller can follow what is built from it.
|
||||
// pull.merged is the events entrypoint's to announce (index.ts), once, within its poll.
|
||||
const merged = await gitea.getPullRequest(owner, repo, number);
|
||||
// And what it changed, so the mesh rebuilds the modules whose own files moved rather than
|
||||
// every module built from the repository (novox/hq 04-ISSUES/131).
|
||||
const changed = await gitea.listPullFiles(owner, repo, number);
|
||||
await emit("pull.merged", {
|
||||
owner,
|
||||
repo,
|
||||
number,
|
||||
title: pull.title,
|
||||
head: pull.head,
|
||||
base: pull.base,
|
||||
merge_commit_sha: merged.merge_commit_sha,
|
||||
merged_at: merged.merged_at,
|
||||
method,
|
||||
html_url: pull.html_url,
|
||||
paths: changed.paths,
|
||||
paths_truncated: changed.truncated,
|
||||
});
|
||||
return { merged: true, number, method, deleted_branch: deleteBranch };
|
||||
return { merged: true, number, method, deleted_branch: deleteBranch, merge_commit_sha: merged.merge_commit_sha };
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
|
||||
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
|
||||
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
|
||||
//
|
||||
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
|
||||
// account, so the file is written through sudo without a prompt where the account is not root, as the
|
||||
// packet filter's is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
|
||||
// together.
|
||||
const HostsPath = "/etc/hosts"
|
||||
|
||||
// Operator is the owner of every line outside a block.
|
||||
const Operator = "operator"
|
||||
|
||||
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
|
||||
// machine.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
program, argv := escalated(os.Getuid(), name, args)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if said != "" {
|
||||
return "", fmt.Errorf("%s: %s", name, said)
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Line is one line of the file, as a reader sees it.
|
||||
type Line struct {
|
||||
// Text is the line exactly as it is in the file.
|
||||
Text string `json:"text"`
|
||||
// Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator".
|
||||
Owner string `json:"owner"`
|
||||
// Address and Names are an entry's; absent for a comment or a blank line.
|
||||
Address string `json:"address,omitempty"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
|
||||
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
|
||||
func isAddress(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Parse is every line of a hosts file, each marked whose it is.
|
||||
func Parse(text string) []Line {
|
||||
out := []Line{}
|
||||
block := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if block == "" {
|
||||
if m := begin.FindStringSubmatch(raw); m != nil {
|
||||
block = m[1]
|
||||
out = append(out, Line{Text: raw, Owner: block})
|
||||
continue
|
||||
}
|
||||
}
|
||||
owner := block
|
||||
if owner == "" {
|
||||
owner = Operator
|
||||
}
|
||||
line := Line{Text: raw, Owner: owner}
|
||||
entry, _, _ := strings.Cut(raw, "#")
|
||||
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
|
||||
line.Address, line.Names = fields[0], fields[1:]
|
||||
}
|
||||
out = append(out, line)
|
||||
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
|
||||
block = ""
|
||||
}
|
||||
}
|
||||
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
|
||||
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
|
||||
out = out[:n-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
|
||||
// Refused input says why, so a caller is one edit from right.
|
||||
func checkAddress(address string) error {
|
||||
if !isAddress(address) {
|
||||
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkName(name string) error {
|
||||
bad := fmt.Errorf("%q is not a host name", name)
|
||||
if len(name) < 1 || len(name) > 253 {
|
||||
return bad
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
|
||||
if !label.MatchString(l) {
|
||||
return bad
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
|
||||
// they are already there.
|
||||
func WithAdded(text, address string, names []string) (string, error) {
|
||||
if err := checkAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", errors.New("add names at least one name for the address")
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := checkName(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner == Operator && l.Address == address {
|
||||
for _, n := range l.Names {
|
||||
have[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for _, n := range names {
|
||||
if !have[n] && !slices.Contains(missing, n) {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
body := text
|
||||
if body != "" && !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
|
||||
}
|
||||
|
||||
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
|
||||
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
|
||||
// writes is refused, naming whose it is.
|
||||
func WithRemoved(text, what string) (string, int, error) {
|
||||
byAddress := isAddress(what)
|
||||
if !byAddress {
|
||||
if err := checkName(what); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
matches := func(l Line) bool {
|
||||
if byAddress {
|
||||
return l.Address == what
|
||||
}
|
||||
return slices.Contains(l.Names, what)
|
||||
}
|
||||
lines := Parse(text)
|
||||
removed := 0
|
||||
kept := []string{}
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator || l.Address == "" || !matches(l) {
|
||||
kept = append(kept, l.Text)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
if byAddress {
|
||||
continue
|
||||
}
|
||||
var rest []string
|
||||
for _, n := range l.Names {
|
||||
if n != what {
|
||||
rest = append(rest, n)
|
||||
}
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator && matches(l) {
|
||||
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n") + "\n", removed, nil
|
||||
}
|
||||
|
||||
// HostsFile is the machine's hosts file.
|
||||
type HostsFile struct {
|
||||
Path string
|
||||
Run Runner
|
||||
}
|
||||
|
||||
// Entries is the file's lines, each marked whose.
|
||||
type Entries struct {
|
||||
Path string `json:"path"`
|
||||
Lines []Line `json:"lines"`
|
||||
}
|
||||
|
||||
func (h HostsFile) read() (string, error) {
|
||||
b, err := os.ReadFile(h.Path)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// Entries is every line of the file, each marked whose it is.
|
||||
func (h HostsFile) Entries() (*Entries, error) {
|
||||
text, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
|
||||
}
|
||||
|
||||
// Added is whether add changed the file, and the line it wrote.
|
||||
type Added struct {
|
||||
Added bool `json:"added"`
|
||||
Line string `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// Add adds one address and its names to the operator's lines.
|
||||
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := WithAdded(before, address, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if after == before {
|
||||
return &Added{Added: false}, nil
|
||||
}
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
|
||||
}
|
||||
|
||||
// Removed is how many of the operator's lines remove touched.
|
||||
type Removed struct {
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
|
||||
// Remove takes one name, or every line of one address, out of the operator's lines.
|
||||
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, removed, err := WithRemoved(before, what)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if removed > 0 {
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Removed{Removed: removed}, nil
|
||||
}
|
||||
|
||||
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
|
||||
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
|
||||
// renamed over it.
|
||||
func (h HostsFile) write(ctx context.Context, content string) error {
|
||||
dir, err := os.MkdirTemp("", "hosts-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
staged := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools")
|
||||
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
|
||||
_, _ = h.Run(ctx, "rm", "-f", beside)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
||||
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const file = "# Static table lookup for hostnames.\n" +
|
||||
"127.0.0.1\tlocaldev.example.com\n" +
|
||||
"127.0.0.1 a.example.com b.example.com\n" +
|
||||
"# BEGIN mesh hosts.own\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\tlocalhost\n" +
|
||||
"# END mesh hosts.own\n" +
|
||||
"# BEGIN other-tool\n" +
|
||||
"192.0.2.7\tproject.test\n" +
|
||||
"# END other-tool\n"
|
||||
|
||||
func blocks(text string) []string {
|
||||
var out []string
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner != Operator {
|
||||
out = append(out, l.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
||||
lines := Parse(file)
|
||||
if len(lines) != 10 {
|
||||
t.Fatalf("%d lines: %+v", len(lines), lines)
|
||||
}
|
||||
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
||||
if !reflect.DeepEqual(lines[1], want) {
|
||||
t.Errorf("%+v", lines[1])
|
||||
}
|
||||
if lines[0].Address != "" || lines[0].Owner != Operator {
|
||||
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
|
||||
}
|
||||
if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" {
|
||||
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
|
||||
}
|
||||
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
|
||||
t.Errorf("%+v", lines[8])
|
||||
}
|
||||
if lines[5].Address != "::1" {
|
||||
t.Errorf("an IPv6 entry: %+v", lines[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
||||
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
||||
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
||||
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
||||
for _, l := range lines {
|
||||
if l.Owner != "x" {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
||||
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
||||
t.Errorf("a name already there changed the file")
|
||||
}
|
||||
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
||||
t.Errorf("%q", some)
|
||||
}
|
||||
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
||||
t.Errorf("a file without a last newline: %q", ended)
|
||||
}
|
||||
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
||||
t.Errorf("an empty file: %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
||||
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
address string
|
||||
names []string
|
||||
says string
|
||||
}{
|
||||
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
||||
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
||||
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
||||
{"192.0.2.9", []string{}, "at least one name"},
|
||||
} {
|
||||
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v", c.address, c.names, err)
|
||||
}
|
||||
}
|
||||
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
||||
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
||||
one, n, err := WithRemoved(file, "a.example.com")
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
||||
t.Errorf("%q", one)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
all, n, err := WithRemoved(file, "127.0.0.1")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(all), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
|
||||
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
||||
t.Errorf("%d %v", n, err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
||||
t.Errorf("a name that is neither an address nor a host name is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
||||
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
}
|
||||
|
||||
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
||||
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls [][]string
|
||||
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
return runPlain(ctx, name, args...)
|
||||
}}
|
||||
ctx := context.Background()
|
||||
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
||||
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
||||
t.Fatalf("%+v %v", added, err)
|
||||
}
|
||||
beside := filepath.Join(dir, ".hosts.hosts-tools")
|
||||
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
|
||||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
|
||||
t.Errorf("%v", calls)
|
||||
}
|
||||
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
||||
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
||||
}
|
||||
got, err := h.Entries()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := got.Lines[len(got.Lines)-1]
|
||||
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
||||
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
||||
}
|
||||
removed, err := h.Remove(ctx, "lab.test")
|
||||
if err != nil || removed.Removed != 1 {
|
||||
t.Fatalf("%+v %v", removed, err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != file {
|
||||
t.Errorf("add then remove gives the file back: %q", b)
|
||||
}
|
||||
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
||||
t.Errorf("the staged copy beside the file is left: %v", err)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode %v", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r.ID == "own" {
|
||||
found = true
|
||||
if r.Path != HostsPath {
|
||||
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no resource own")
|
||||
}
|
||||
var served []string
|
||||
for _, tool := range tools(HostsFile{}) {
|
||||
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
||||
if tool.Description == "" || tool.Run == nil {
|
||||
t.Errorf("%s", tool.Name)
|
||||
}
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
||||
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
||||
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{}); len(got) != 0 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// hosts-tools (novox/hq ADR 0199): the hosts file's tools. One binary, launched by the machine's tool
|
||||
// runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198): the
|
||||
// node-hosts-file seat's three verbs — the file's lines with whose each is, add an operator's line,
|
||||
// remove one. They change the machine's file and nothing else; the controller holds none of it.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-hosts-file"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hosts] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
var separators = regexp.MustCompile(`[\s,]+`)
|
||||
|
||||
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
|
||||
// the list.
|
||||
func namesArg(a map[string]any) []string {
|
||||
var raw []string
|
||||
switch v := a["names"].(type) {
|
||||
case string:
|
||||
raw = separators.Split(v, -1)
|
||||
case []any:
|
||||
for _, n := range v {
|
||||
if s, ok := n.(string); ok {
|
||||
raw = append(raw, separators.Split(s, -1)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
names := []string{}
|
||||
for _, n := range raw {
|
||||
if n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject, as <node>/node-hosts-file.<verb>.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(h HostsFile) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
return []stdio.Tool{
|
||||
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
|
||||
nil, func(map[string]any) (any, error) { return h.Entries() }),
|
||||
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
|
||||
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
|
||||
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
|
||||
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||
map[string]any{"name": str("a host name, or an address to remove every line of")},
|
||||
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module hosts
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"module": "hosts",
|
||||
"version": "1",
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-hosts-file",
|
||||
"scope": "node",
|
||||
"serves": [
|
||||
"entries",
|
||||
"add",
|
||||
"remove"
|
||||
]
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "own",
|
||||
"type": "file",
|
||||
"path": "/etc/hosts",
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/hosts-tools",
|
||||
"binary": "hosts-tools",
|
||||
"loads": [
|
||||
"hosts-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 ####
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in the nextcloud-client and blueman 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
|
||||
@@ -251,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") {
|
||||
@@ -411,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 {
|
||||
@@ -451,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)
|
||||
|
||||
@@ -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 and blueman 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") {
|
||||
|
||||
@@ -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) | <ul><li>whether the applet runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and what starts it at login</li><li>its switches in `org.gnome.nm-applet`</li><li>NetworkManager's overall state: state, connectivity, Wi-Fi and networking on or off. Never a connection, a network, a secret or an address</li></ul> |
|
||||
| `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) | <ul><li>the package is installed</li><li>exactly one start: the package's entry is present and not hidden by an entry of the account, and `dex` is installed</li><li>no window-manager exec</li><li>one applet runs in a desktop session</li><li>`NetworkManager.service` is active</li></ul>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).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module nm-applet
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
# openrazer
|
||||
|
||||
The Razer peripherals' kernel driver and the account's daemon that drives it, on the workstations, as
|
||||
a module (novox/hq ADR 0208: one module per piece of software). The tray that shows the devices is
|
||||
the `polychromatic` module's. This module needs no display: the daemon speaks to the driver and to its
|
||||
clients on the session bus.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| the kernel driver's source, built by DKMS for each installed kernel (`razerkbd`, `razermouse`, `razerkraken`, `razeraccessory`) | package `openrazer-driver-dkms` |
|
||||
| the daemon (`org.razer` on the session bus) and its user unit | package `openrazer-daemon` |
|
||||
| the client library every front end speaks to the daemon through | package `python-openrazer` |
|
||||
|
||||
All three from the official repositories (`extra`). On both workstations they are installed today as
|
||||
dependencies of the AUR tray, and become the mesh's here.
|
||||
|
||||
**Why two modules and not one `razer`:** the driver and the daemon are one project, in the official
|
||||
repositories, and any front end uses them. The tray is another project, outside the official
|
||||
repositories. That is the line between `bluetooth` and `blueman` too. A machine can hold the stack
|
||||
without the tray, for a front end of the operator's own or for the daemon's persistence of the
|
||||
devices' lighting and DPI.
|
||||
|
||||
Not this module's:
|
||||
|
||||
- **The kernel headers DKMS builds against** (`linux-headers`). They are the kernel's, and every DKMS
|
||||
driver on a machine needs them (the laptop also builds `nvidia`, the desktop `vboxhost` and `xone`).
|
||||
No module declares them yet. `openrazer_check` says when the driver is not built for the running
|
||||
kernel.
|
||||
- **`dkms` itself**, which the driver package depends on.
|
||||
- **The account's membership of the `openrazer` group** (below).
|
||||
|
||||
## The group: a step for the operator, once
|
||||
|
||||
The driver's udev rules give each device's files to the group `openrazer`, which the driver package
|
||||
creates (sysusers). The daemon refuses to start for an account outside that group: *User is not a
|
||||
member of the openrazer group*.
|
||||
|
||||
**On both workstations the account is not in it today, so the daemon has failed at every start**
|
||||
since openrazer moved from `plugdev` to its own group. The tray runs, and shows no devices. The
|
||||
account is still in `plugdev`, which openrazer no longer uses. Another device's rules may (the laptop
|
||||
has a Logitech receiver rule that does), so it stays.
|
||||
|
||||
The module cannot declare the membership. The host's `user` resource takes groups, additively, but the
|
||||
`zsh` module already declares the operator's account as its `user` resource. A second module declaring
|
||||
the same account is refused at composition, as two owners of one name. Until the mesh can add a group
|
||||
to the account from a second module, this is the operator's step (below), and `openrazer_check` holds
|
||||
it.
|
||||
|
||||
## How it starts: D-Bus activation, and nothing else
|
||||
|
||||
The package installs `org.razer` as a D-Bus service whose `SystemdService` is the user unit
|
||||
`openrazer-daemon.service`. The first client that asks for `org.razer` starts the daemon through that
|
||||
unit: at login, the tray's helper (`polychromatic-helper --autostart`). **That activation is the
|
||||
daemon's one start.** One unit, so it is never two daemons.
|
||||
|
||||
- The unit is **not enabled** on either workstation, and the module does not enable it. Enabling it
|
||||
would start the same unit at login a moment earlier, with nothing gained.
|
||||
- Asked by a tool, the bus is always called with `--auto-start=no`, so asking never starts it.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175).
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `openrazer_status` (r) | <ul><li>the three packages' versions</li><li>the driver: the running kernel, DKMS's state for it, the modules loaded, the devices bound (USB id, driver, interfaces)</li><li>the group: whether the account is in it, and whether the account's running service manager has it</li><li>the daemon: its unit's active and enabled states, its process, its version, and, when its unit failed, the reason it gave</li><li>each device the daemon sees: name, type, firmware, battery and charging where the device has them. Never its serial</li></ul> |
|
||||
| `openrazer_restart` (a) | restarts `openrazer-daemon.service` in the account's service manager and answers the devices it then sees. When it fails, the answer carries the daemon's own reason |
|
||||
| `openrazer_check` (r) | <ul><li>the packages are installed</li><li>the driver is built for the running kernel, and loaded</li><li>a device is bound</li><li>the account is in `openrazer`, and its service manager has the group (a group added after login needs a new login)</li><li>one start: the activation file is present, no window-manager exec</li><li>one daemon runs, from its unit, and answers</li><li>it sees every device the driver holds</li></ul>Each finding says what to do |
|
||||
|
||||
Every command has a timeout and capped output. Everything runs through an injected runner and a fake
|
||||
root in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | laptop | desktop |
|
||||
|---|---|---|
|
||||
| packages | none: the three are installed, 3.12.4, as dependencies of the tray | the same |
|
||||
| driver | none: built for the running kernel, `razermouse` loaded, a Basilisk V3 Pro bound | the same, a Basilisk V2 bound |
|
||||
| daemon | none: the unit stays disabled; it failed at login (not in the group) | the same |
|
||||
|
||||
## Migration (ADR 0182)
|
||||
|
||||
On each workstation, once:
|
||||
|
||||
1. `sudo gpasswd -a $USER openrazer`
|
||||
2. Log out of every session, or reboot. The account's service manager takes its groups when it
|
||||
starts, and the daemon runs under it.
|
||||
3. `openrazer_check` answers `ok`, and the tray shows the devices.
|
||||
|
||||
`plugdev` stays. Nothing else is required.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- The daemon's settings and persistence under `~/.config/openrazer/`, and its log under
|
||||
`~/.local/share/openrazer/`.
|
||||
- `plugdev` and every other group of the account.
|
||||
- The kernel headers and DKMS.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **The account in `openrazer`**, by hand (above).
|
||||
- **A client to start the daemon.** At login that is the `polychromatic` module's tray helper.
|
||||
Without a client nothing asks, and nothing needs it to run.
|
||||
- **The kernel headers of every installed kernel**, for DKMS.
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// The openrazer module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Razer
|
||||
// peripherals' kernel driver and the account's daemon that speaks to it, served by the node's runtime
|
||||
// as the operator account. The module holds no seat, so every tool is its own. The tray that drives
|
||||
// the daemon is the polychromatic module's.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools()); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var machine = NewMachine()
|
||||
|
||||
func tools() []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{
|
||||
Name: "openrazer_status",
|
||||
Description: "The Razer stack: the installed versions, the driver (built by DKMS for the running " +
|
||||
"kernel or not, its kernel modules loaded, the devices bound to it), the account's openrazer " +
|
||||
"group (in the group file, and in its running service manager), the daemon (its unit's state, " +
|
||||
"its process, its version) and each device the daemon sees: name, type, firmware, battery. " +
|
||||
"Asks the daemon without starting it. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "openrazer_restart",
|
||||
Description: "Restart the daemon through its unit in the account's service manager (the unit D-Bus " +
|
||||
"activation starts), and answer its state and the devices it then sees. Needs the account " +
|
||||
"logged in. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "openrazer_check",
|
||||
Description: "Check what the module promises and relies on: the packages are installed; the driver " +
|
||||
"is built for the running kernel and loaded; the account is in the openrazer group, and its " +
|
||||
"service manager has it; the daemon runs once, from its unit, and sees the devices the driver " +
|
||||
"has. Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// openrazer's shape (novox/hq ADR 0207, ADR 0208, ADR 0210): the three official packages of the
|
||||
// driver, the daemon and its client library, no seat, no display (the daemon needs none), no start of
|
||||
// its own (D-Bus activation through the package's unit is the one start), nothing of the tray's (the
|
||||
// polychromatic module's), and the Go bundle serving exactly the listed openrazer_ tools.
|
||||
|
||||
type manifest struct {
|
||||
Module string `json:"module"`
|
||||
Version string `json:"version"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
Requires []string `json:"requires"`
|
||||
Tools []string `json:"tools"`
|
||||
Resources []map[string]any `json:"resources"`
|
||||
Claims []any `json:"claims"`
|
||||
Seats []any `json:"seats"`
|
||||
Shell []any `json:"shell"`
|
||||
Contributions []struct {
|
||||
Seat string `json:"seat"`
|
||||
Kind string `json:"kind"`
|
||||
Content string `json:"content"`
|
||||
} `json:"contributions"`
|
||||
Environment any `json:"environment"`
|
||||
Build struct {
|
||||
Artifacts []map[string]any `json:"artifacts"`
|
||||
} `json:"build"`
|
||||
}
|
||||
|
||||
func readManifest(t *testing.T) (manifest, string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.DisallowUnknownFields()
|
||||
var m manifest
|
||||
if err := dec.Decode(&m); err != nil {
|
||||
t.Fatalf("module.json: %v", err)
|
||||
}
|
||||
return m, string(raw)
|
||||
}
|
||||
|
||||
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
served := map[string]bool{}
|
||||
for _, tool := range tools() {
|
||||
served[tool.Name] = true
|
||||
if !strings.HasPrefix(tool.Name, "openrazer_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed %s and described", tool.Name, "openrazer_")
|
||||
}
|
||||
}
|
||||
for _, name := range m.Tools {
|
||||
if !served[name] {
|
||||
t.Errorf("module.json lists %s, which the bundle does not serve", name)
|
||||
}
|
||||
delete(served, name)
|
||||
}
|
||||
for name := range served {
|
||||
t.Errorf("the bundle serves %s, which module.json does not list", name)
|
||||
}
|
||||
if len(m.Build.Artifacts) != 1 {
|
||||
t.Fatalf("%v", m.Build.Artifacts)
|
||||
}
|
||||
b := m.Build.Artifacts[0]
|
||||
if b["kind"] != "bundle" || b["language"] != "go" || b["system"] != "arch" ||
|
||||
b["from"] != "cmd/openrazer-tools" || b["binary"] != "openrazer-tools" {
|
||||
t.Errorf("the Go tools bundle: %v", b)
|
||||
}
|
||||
s := strings.ToLower(raw)
|
||||
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "http", "password", "token"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItInstallsTheStackAndStartsNothing(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "openrazer" || m.Requires != nil || !reflect.DeepEqual(m.Capabilities, []string{"package-manager"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
var pkgs []string
|
||||
for _, r := range m.Resources {
|
||||
if r["type"] != "package" {
|
||||
t.Errorf("only packages: %v", r)
|
||||
}
|
||||
pkgs = append(pkgs, r["package"].(string))
|
||||
}
|
||||
if !reflect.DeepEqual(pkgs, packages) {
|
||||
t.Fatalf("%v", pkgs)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil || m.Contributions != nil {
|
||||
t.Fatal("it holds no seat, sets no environment and adds no start")
|
||||
}
|
||||
// The tray is polychromatic's, from outside the official repositories; the kernel headers DKMS
|
||||
// builds against are the kernel's; the account's groups are its user resource's (zsh's).
|
||||
for _, never := range []string{"polychromatic", "linux-headers", "\"dkms\"", "\"user\"", "groups"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %s", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
package main
|
||||
|
||||
// openrazer as the tools see it: the kernel driver (built by DKMS, loaded, holding devices), the
|
||||
// account's membership of the group the driver gives its devices to, and the daemon in the account's
|
||||
// service manager, asked over the session bus. Every bus call is made with --auto-start=no: the daemon
|
||||
// is D-Bus activatable, and a question must never start it.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
dkmsName = "openrazer-driver"
|
||||
group = "openrazer"
|
||||
daemonComm = "openrazer-daemo" // the kernel keeps 15 characters of openrazer-daemon
|
||||
daemonUnit = "openrazer-daemon.service"
|
||||
busName = "org.razer"
|
||||
busRoot = "/org/razer"
|
||||
activation = "/usr/share/dbus-1/services/org.razer.service"
|
||||
// mostDevices bounds the questions one answer asks the daemon.
|
||||
mostDevices = 16
|
||||
)
|
||||
|
||||
// packages are the module's, from the official repositories: the driver's DKMS source, the daemon,
|
||||
// and the Python library every front end (the tray among them) speaks to the daemon through.
|
||||
var packages = []string{"openrazer-driver-dkms", "openrazer-daemon", "python-openrazer"}
|
||||
|
||||
// kernelModules are what the DKMS package builds (its dkms.conf).
|
||||
var kernelModules = []string{"razerkbd", "razermouse", "razerkraken", "razeraccessory"}
|
||||
|
||||
// hidID is a HID device's name under its driver: bus:vendor:product.instance.
|
||||
// stamp is the daemon's own time at the front of its log lines, dropped so repeats are seen as one.
|
||||
var stamp = regexp.MustCompile(`^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\s*\|\s*`)
|
||||
|
||||
var hidID = regexp.MustCompile(`^[0-9A-Fa-f]{4}:([0-9A-Fa-f]{4}):([0-9A-Fa-f]{4})\.[0-9A-Fa-f]{4}$`)
|
||||
|
||||
// Bound is one device the driver holds: its USB id, the driver module and how many of its HID
|
||||
// interfaces are bound.
|
||||
type Bound struct {
|
||||
USB string `json:"usb"`
|
||||
Driver string `json:"driver"`
|
||||
Interfaces int `json:"interfaces"`
|
||||
}
|
||||
|
||||
// Driver is the kernel side.
|
||||
type Driver struct {
|
||||
Kernel string `json:"running_kernel"`
|
||||
// Built is DKMS's word for the driver and the running kernel: "installed" when it is built and
|
||||
// installed for it, "" when DKMS has nothing for it.
|
||||
Built string `json:"built,omitempty"`
|
||||
Loaded []string `json:"loaded"`
|
||||
Bound []Bound `json:"bound"`
|
||||
}
|
||||
|
||||
// Group is the account's membership of the group the driver's udev rules give the devices to.
|
||||
type Group struct {
|
||||
Name string `json:"name"`
|
||||
Exists bool `json:"exists"`
|
||||
Account bool `json:"account_in_group"`
|
||||
// Manager is whether the account's running service manager has the group. It took its groups when
|
||||
// it started, at the account's first login, and the daemon runs under it.
|
||||
Manager *bool `json:"service_manager_has_it,omitempty"`
|
||||
}
|
||||
|
||||
// Device is one device as the daemon sees it. Its serial is not answered.
|
||||
type Device struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"`
|
||||
Firmware string `json:"firmware,omitempty"`
|
||||
Battery *float64 `json:"battery_percent,omitempty"`
|
||||
Charging *bool `json:"charging,omitempty"`
|
||||
}
|
||||
|
||||
// Daemon is the account's daemon.
|
||||
type Daemon struct {
|
||||
Active string `json:"unit_active"`
|
||||
Enabled string `json:"unit_enabled"`
|
||||
Running []Proc `json:"running"`
|
||||
// StartedBy is what starts it: D-Bus activation of org.razer, through its unit.
|
||||
StartedBy string `json:"started_by"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Unanswered string `json:"unanswered,omitempty"`
|
||||
LastWords []string `json:"last_words,omitempty"`
|
||||
}
|
||||
|
||||
// StatusAnswer is what openrazer_status answers.
|
||||
type StatusAnswer struct {
|
||||
Installed map[string]string `json:"installed"`
|
||||
Driver Driver `json:"driver"`
|
||||
Group Group `json:"group"`
|
||||
Daemon Daemon `json:"daemon"`
|
||||
Devices []Device `json:"devices"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
// busCall asks the daemon one question and answers busctl's data.
|
||||
func (m *Machine) busCall(path, iface, method string) (json.RawMessage, error) {
|
||||
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, path, iface, method}
|
||||
o := m.cmd(5*time.Second, m.bus(), "busctl", args...)
|
||||
if err := failed(o, "busctl", args...); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var doc struct {
|
||||
Data []json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(o.Stdout), &doc); err != nil || len(doc.Data) != 1 {
|
||||
return nil, fmt.Errorf("the daemon's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
|
||||
}
|
||||
return doc.Data[0], nil
|
||||
}
|
||||
|
||||
func (m *Machine) busString(path, iface, method string) (string, error) {
|
||||
raw, err := m.busCall(path, iface, method)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var s string
|
||||
if err := json.Unmarshal(raw, &s); err != nil {
|
||||
return "", fmt.Errorf("the daemon's %s is not a string: %w", method, err)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// devices asks the daemon for the devices it holds, and each one's name, type, firmware and battery.
|
||||
func (m *Machine) devices() ([]Device, string, error) {
|
||||
version, err := m.busString(busRoot, "razer.daemon", "version")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
raw, err := m.busCall(busRoot, "razer.devices", "getDevices")
|
||||
if err != nil {
|
||||
return nil, version, err
|
||||
}
|
||||
var serials []string
|
||||
if err := json.Unmarshal(raw, &serials); err != nil {
|
||||
return nil, version, fmt.Errorf("the daemon's device list is not a list of names: %w", err)
|
||||
}
|
||||
if len(serials) > mostDevices {
|
||||
serials = serials[:mostDevices]
|
||||
}
|
||||
out := []Device{}
|
||||
for _, serial := range serials {
|
||||
path := busRoot + "/device/" + serial
|
||||
d := Device{}
|
||||
d.Name, _ = m.busString(path, "razer.device.misc", "getDeviceName")
|
||||
d.Type, _ = m.busString(path, "razer.device.misc", "getDeviceType")
|
||||
d.Firmware, _ = m.busString(path, "razer.device.misc", "getFirmware")
|
||||
if raw, err := m.busCall(path, "razer.device.power", "getBattery"); err == nil {
|
||||
var pct float64
|
||||
if json.Unmarshal(raw, &pct) == nil {
|
||||
d.Battery = &pct
|
||||
}
|
||||
}
|
||||
if raw, err := m.busCall(path, "razer.device.power", "isCharging"); err == nil {
|
||||
var on bool
|
||||
if json.Unmarshal(raw, &on) == nil {
|
||||
d.Charging = &on
|
||||
}
|
||||
}
|
||||
if d.Name == "" {
|
||||
d.Name = "(unnamed)"
|
||||
}
|
||||
out = append(out, d)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
|
||||
return out, version, nil
|
||||
}
|
||||
|
||||
// driver reads the kernel side: DKMS's state for the running kernel, the loaded modules, and the
|
||||
// devices bound to them.
|
||||
func (m *Machine) driver() Driver {
|
||||
d := Driver{Kernel: readTrimmed(m.path("/proc/sys/kernel/osrelease")), Loaded: []string{}, Bound: []Bound{}}
|
||||
if d.Kernel != "" {
|
||||
o := m.cmd(0, nil, "dkms", "status", dkmsName, "-k", d.Kernel)
|
||||
for _, l := range strings.Split(o.Stdout, "\n") {
|
||||
if strings.HasPrefix(l, dkmsName+"/") && strings.Contains(l, d.Kernel) {
|
||||
if i := strings.LastIndex(l, ":"); i > 0 {
|
||||
d.Built = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, k := range kernelModules {
|
||||
if exists(m.path(filepath.Join("/sys/module", k))) {
|
||||
d.Loaded = append(d.Loaded, k)
|
||||
}
|
||||
}
|
||||
drivers, _ := os.ReadDir(m.path("/sys/bus/hid/drivers"))
|
||||
seen := map[string]int{}
|
||||
for _, drv := range drivers {
|
||||
if !strings.HasPrefix(drv.Name(), "razer") {
|
||||
continue
|
||||
}
|
||||
entries, _ := os.ReadDir(m.path(filepath.Join("/sys/bus/hid/drivers", drv.Name())))
|
||||
for _, e := range entries {
|
||||
if f := hidID.FindStringSubmatch(e.Name()); f != nil {
|
||||
usb := strings.ToLower(f[1] + ":" + f[2])
|
||||
if i, ok := seen[drv.Name()+" "+usb]; ok {
|
||||
d.Bound[i].Interfaces++
|
||||
continue
|
||||
}
|
||||
seen[drv.Name()+" "+usb] = len(d.Bound)
|
||||
d.Bound = append(d.Bound, Bound{USB: usb, Driver: drv.Name(), Interfaces: 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// account is the operator account's name, from the user database by uid.
|
||||
func (m *Machine) account() string {
|
||||
raw, _ := readBounded(m.path("/etc/passwd"))
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(l, ":")
|
||||
if len(f) > 2 && f[2] == strconv.Itoa(m.UID) {
|
||||
return f[0]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// groupOf reads one group's gid and members from the group file.
|
||||
func (m *Machine) groupOf(name string) (gid int, members []string, found bool) {
|
||||
raw, _ := readBounded(m.path("/etc/group"))
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
f := strings.Split(l, ":")
|
||||
if len(f) == 4 && f[0] == name {
|
||||
gid, _ = strconv.Atoi(f[2])
|
||||
for _, who := range strings.Split(f[3], ",") {
|
||||
if who = strings.TrimSpace(who); who != "" {
|
||||
members = append(members, who)
|
||||
}
|
||||
}
|
||||
return gid, members, true
|
||||
}
|
||||
}
|
||||
return 0, nil, false
|
||||
}
|
||||
|
||||
func contains(list []string, s string) bool {
|
||||
for _, x := range list {
|
||||
if x == s {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// managerGroups are the supplementary groups of the account's own service manager; ok is false when
|
||||
// it does not run.
|
||||
func (m *Machine) managerGroups() (groups []int, ok bool) {
|
||||
for _, p := range m.procs("systemd") {
|
||||
dir := m.path(filepath.Join("/proc", strconv.Itoa(p.PID)))
|
||||
if !strings.Contains(readTrimmed(filepath.Join(dir, "cgroup")), fmt.Sprintf("user@%d.service", m.UID)) {
|
||||
continue
|
||||
}
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 0 && f[0] == "Groups:" {
|
||||
for _, g := range f[1:] {
|
||||
if n, err := strconv.Atoi(g); err == nil {
|
||||
groups = append(groups, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func (m *Machine) group() Group {
|
||||
g := Group{Name: group}
|
||||
gid, members, found := m.groupOf(group)
|
||||
if !found {
|
||||
return g
|
||||
}
|
||||
g.Exists = true
|
||||
g.Account = contains(members, m.account())
|
||||
if in, ok := m.managerGroups(); ok {
|
||||
has := false
|
||||
for _, n := range in {
|
||||
has = has || n == gid
|
||||
}
|
||||
g.Manager = &has
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// unit answers the daemon's unit's active and enabled states in the account's service manager.
|
||||
func (m *Machine) unit() (active, enabled string) {
|
||||
word := func(verb string) string {
|
||||
o := m.cmd(5*time.Second, m.bus(), "systemctl", "--user", verb, daemonUnit)
|
||||
if o.Err != nil {
|
||||
return "unknown"
|
||||
}
|
||||
if w := strings.TrimSpace(o.Stdout); w != "" {
|
||||
return strings.Fields(w)[0]
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
return word("is-active"), word("is-enabled")
|
||||
}
|
||||
|
||||
// lastWords are the daemon's last lines in the account's journal, for a unit that failed: its critical
|
||||
// and error lines when it wrote any (the reason, not the traceback of its exit), else the last lines.
|
||||
func (m *Machine) lastWords() []string {
|
||||
o := m.cmd(5*time.Second, m.bus(), "journalctl", "--user", "-u", daemonUnit, "-n", "20", "-o", "cat", "--no-pager")
|
||||
var all, said []string
|
||||
seen := map[string]bool{}
|
||||
for _, l := range strings.Split(o.Stdout, "\n") {
|
||||
if l = strings.TrimSpace(l); l == "" {
|
||||
continue
|
||||
}
|
||||
l = tail(stamp.ReplaceAllString(l, ""), 300)
|
||||
all = append(all, l)
|
||||
if (strings.Contains(l, "CRITICAL") || strings.Contains(l, "ERROR")) && !seen[l] {
|
||||
seen[l] = true
|
||||
said = append(said, l)
|
||||
}
|
||||
}
|
||||
if len(said) > 0 {
|
||||
return said
|
||||
}
|
||||
return lastOf(all, 4)
|
||||
}
|
||||
|
||||
func (m *Machine) daemon() (Daemon, []Device) {
|
||||
d := Daemon{Running: m.procs(daemonComm), StartedBy: "D-Bus activation of " + busName + " through " + daemonUnit}
|
||||
if d.Running == nil {
|
||||
d.Running = []Proc{}
|
||||
}
|
||||
if !exists(m.path(activation)) {
|
||||
d.StartedBy = "nothing: the package's activation file " + activation + " is missing"
|
||||
}
|
||||
d.Active, d.Enabled = m.unit()
|
||||
if d.Active == "failed" {
|
||||
d.LastWords = m.lastWords()
|
||||
}
|
||||
devices := []Device{}
|
||||
if len(d.Running) == 0 {
|
||||
d.Unanswered = "the daemon is not running (asked without starting it)"
|
||||
return d, devices
|
||||
}
|
||||
got, version, err := m.devices()
|
||||
d.Version = version
|
||||
if err != nil {
|
||||
d.Unanswered = err.Error()
|
||||
return d, devices
|
||||
}
|
||||
return d, got
|
||||
}
|
||||
|
||||
// Status reads the whole stack.
|
||||
func (m *Machine) Status() (StatusAnswer, error) {
|
||||
s := StatusAnswer{Installed: map[string]string{}, Driver: m.driver(), Group: m.group()}
|
||||
for _, p := range packages {
|
||||
v, err := m.installed(p)
|
||||
if err != nil {
|
||||
return s, err
|
||||
}
|
||||
s.Installed[p] = v
|
||||
}
|
||||
s.Daemon, s.Devices = m.daemon()
|
||||
if _, members, ok := m.groupOf("plugdev"); ok && contains(members, m.account()) {
|
||||
s.Notes = append(s.Notes, "the account is in plugdev, which this openrazer does not use: its udev rules "+
|
||||
"give the devices to the openrazer group. Other devices' rules may use plugdev; the module leaves it")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// RestartAnswer is what openrazer_restart answers.
|
||||
type RestartAnswer struct {
|
||||
Active string `json:"unit_active"`
|
||||
Running []Proc `json:"running"`
|
||||
Version string `json:"version,omitempty"`
|
||||
Devices []Device `json:"devices"`
|
||||
}
|
||||
|
||||
// Restart restarts the daemon's unit in the account's service manager and waits for it to answer.
|
||||
func (m *Machine) Restart() (RestartAnswer, error) {
|
||||
a := RestartAnswer{Devices: []Device{}}
|
||||
args := []string{"--user", "restart", daemonUnit}
|
||||
if err := failed(m.cmd(15*time.Second, m.bus(), "systemctl", args...), "systemctl", args...); err != nil {
|
||||
words := m.lastWords()
|
||||
return a, fmt.Errorf("the daemon did not start: %v. Its last words: %s. See openrazer_check", err, strings.Join(words, " | "))
|
||||
}
|
||||
a.Active, _ = m.unit()
|
||||
a.Running = m.waitFor(daemonComm, 4*time.Second)
|
||||
devices, version, err := m.devices()
|
||||
a.Version = version
|
||||
if err != nil {
|
||||
return a, fmt.Errorf("the daemon started but does not answer: %v", err)
|
||||
}
|
||||
a.Devices = devices
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what openrazer_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies the stack from the packages to the devices the daemon sees.
|
||||
func (m *Machine) Check() (CheckAnswer, error) {
|
||||
a := CheckAnswer{Findings: []Finding{}, Starts: []string{}}
|
||||
add := func(what, do string) { a.Findings = append(a.Findings, Finding{what, do}) }
|
||||
for _, p := range packages {
|
||||
v, err := m.installed(p)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if v == "" {
|
||||
add("the package "+p+" is not installed", "push the module to the node")
|
||||
}
|
||||
}
|
||||
|
||||
d := m.driver()
|
||||
switch {
|
||||
case d.Kernel == "":
|
||||
add("the running kernel's release is unreadable", "")
|
||||
case d.Built != "installed":
|
||||
add("the driver is not built for the running kernel "+d.Kernel+" (DKMS: "+orNone(d.Built)+")",
|
||||
"install the headers of the running kernel (linux-headers), then `sudo dkms autoinstall`; or reboot into the kernel it was built for")
|
||||
}
|
||||
if len(d.Bound) == 0 {
|
||||
add("no Razer device is bound to the driver", "plug the device in; if it is plugged in, openrazer_status shows which driver modules are loaded")
|
||||
}
|
||||
if len(d.Loaded) == 0 && d.Built == "installed" {
|
||||
add("none of the driver's modules is loaded", "plug the device in, which loads its module, or `sudo modprobe razermouse` (razerkbd, razerkraken, razeraccessory)")
|
||||
}
|
||||
|
||||
g := m.group()
|
||||
switch {
|
||||
case !g.Exists:
|
||||
add("the group openrazer does not exist", "it comes with openrazer-driver-dkms (sysusers): reinstall it, or `sudo systemd-sysusers`")
|
||||
case !g.Account:
|
||||
add("the account is not in the openrazer group: the daemon refuses to start, and the devices' files are the group's",
|
||||
"`sudo gpasswd -a $USER openrazer`, then log out of every session (or reboot)")
|
||||
case g.Manager != nil && !*g.Manager:
|
||||
add("the account is in the openrazer group, but its running service manager started before it was",
|
||||
"log out of every session (or reboot), so the service manager starts again with the group")
|
||||
}
|
||||
|
||||
if exists(m.path(activation)) {
|
||||
a.Starts = append(a.Starts, "D-Bus activation: "+busName+" through "+daemonUnit)
|
||||
} else {
|
||||
add("the package's activation file "+activation+" is missing: nothing starts the daemon", "reinstall openrazer-daemon")
|
||||
}
|
||||
for _, l := range m.i3Starts("openrazer-daemon") {
|
||||
a.Starts = append(a.Starts, "window manager: "+l)
|
||||
add("a second start: "+l, "remove the line; D-Bus activation starts the daemon when a client asks")
|
||||
}
|
||||
|
||||
daemon, devices := m.daemon()
|
||||
switch {
|
||||
case len(daemon.Running) == 0 && daemon.Active == "failed":
|
||||
add("the daemon failed at its last start: "+strings.Join(lastOf(daemon.LastWords, 2), " | "), "fix what it says, then openrazer_restart")
|
||||
case len(daemon.Running) == 0:
|
||||
add("the daemon is not running", "openrazer_restart; at login the tray's helper asks for it, which starts it")
|
||||
case len(daemon.Running) > 1:
|
||||
add(fmt.Sprintf("%d daemons run", len(daemon.Running)), "openrazer_restart ends them and starts one, from its unit")
|
||||
default:
|
||||
if in := daemon.Running[0].StartedIn; in != daemonUnit {
|
||||
add("the daemon runs outside its unit (in "+in+"): started by hand or by a second start", "openrazer_restart")
|
||||
}
|
||||
}
|
||||
if daemon.Unanswered != "" && len(daemon.Running) > 0 {
|
||||
add("the daemon does not answer: "+daemon.Unanswered, "openrazer_restart")
|
||||
}
|
||||
if len(daemon.Running) > 0 && daemon.Unanswered == "" && len(devices) < len(d.Bound) {
|
||||
add(fmt.Sprintf("the driver holds %d device(s) and the daemon sees %d", len(d.Bound), len(devices)), "openrazer_restart")
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
return "nothing for it"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func lastOf(lines []string, n int) []string {
|
||||
if len(lines) <= n {
|
||||
return lines
|
||||
}
|
||||
return lines[len(lines)-n:]
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const serial = "PM2148H00000001"
|
||||
|
||||
// newStack is a machine with the stack as it should be: built and loaded, a mouse bound on three
|
||||
// interfaces, the account in the group (and its service manager with it), the daemon running from its
|
||||
// unit and answering. member and running take parts of it away.
|
||||
func newStack(t *testing.T, member, running bool) *fake {
|
||||
f := newFake(t)
|
||||
f.write("/proc/sys/kernel/osrelease", "7.2.8-arch1-2\n")
|
||||
f.write("/sys/module/razermouse/refcnt", "0\n")
|
||||
for _, i := range []string{"0004", "0005", "0006"} {
|
||||
f.write("/sys/bus/hid/drivers/razermouse/0003:1532:00AB."+i+"/device_type", "")
|
||||
}
|
||||
f.write("/sys/bus/hid/drivers/razermouse/bind", "")
|
||||
f.write("/etc/passwd", "root:x:0:0::/root:/bin/sh\noperator:x:1000:1000::/home/operator:/bin/zsh\n")
|
||||
members := "operator"
|
||||
if !member {
|
||||
members = ""
|
||||
}
|
||||
f.write("/etc/group", "plugdev:x:970:operator\nopenrazer:x:958:"+members+"\n")
|
||||
f.write(activation, "[D-BUS Service]\nName=org.razer\nSystemdService=openrazer-daemon.service\n")
|
||||
f.proc(3596, 1000, "systemd", []string{"/usr/lib/systemd/systemd", "--user"}, "user@1000.service/init.scope")
|
||||
groups := "970 958"
|
||||
if !member {
|
||||
groups = "970"
|
||||
}
|
||||
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t"+groups+"\n")
|
||||
if running {
|
||||
f.proc(4269, 1000, daemonComm, []string{"openrazer-daemon", "-F"}, "user@1000.service/app.slice/"+daemonUnit)
|
||||
}
|
||||
f.answer = func(name string, args []string) Output {
|
||||
call := name + " " + strings.Join(args, " ")
|
||||
switch {
|
||||
case name == "pacman":
|
||||
return Output{Stdout: args[1] + " 3.12.4-1\n"}
|
||||
case name == "dkms":
|
||||
return Output{Stdout: "openrazer-driver/3.12.4, 7.2.8-arch1-2, x86_64: installed\n"}
|
||||
case name == "systemctl" && strings.Contains(call, "is-active"):
|
||||
if running {
|
||||
return Output{Stdout: "active\n"}
|
||||
}
|
||||
return Output{Stdout: "failed\n", Code: 3}
|
||||
case name == "systemctl" && strings.Contains(call, "is-enabled"):
|
||||
return Output{Stdout: "disabled\n", Code: 1}
|
||||
case name == "journalctl":
|
||||
return Output{Stdout: "Starting daemon.\n2026-10-04 16:26:21 | razer | CRITICAL | User is not a member of the openrazer group\n" +
|
||||
"Traceback (most recent call last):\n2026-10-04 16:26:22 | razer | CRITICAL | User is not a member of the openrazer group\nSystemExit: 0\n"}
|
||||
case name == "busctl" && !running:
|
||||
return Output{Code: 1, Stderr: "Call failed: The name is not activatable"}
|
||||
case strings.HasSuffix(call, " version"):
|
||||
return Output{Stdout: `{"type":"s","data":["3.12.4"]}`}
|
||||
case strings.HasSuffix(call, " getDevices"):
|
||||
return Output{Stdout: `{"type":"as","data":[["` + serial + `"]]}`}
|
||||
case strings.HasSuffix(call, " getDeviceName"):
|
||||
return Output{Stdout: `{"type":"s","data":["Razer Basilisk V3 Pro"]}`}
|
||||
case strings.HasSuffix(call, " getDeviceType"):
|
||||
return Output{Stdout: `{"type":"s","data":["mouse"]}`}
|
||||
case strings.HasSuffix(call, " getFirmware"):
|
||||
return Output{Stdout: `{"type":"s","data":["v1.04"]}`}
|
||||
case strings.HasSuffix(call, " getBattery"):
|
||||
return Output{Stdout: `{"type":"d","data":[85.0]}`}
|
||||
case strings.HasSuffix(call, " isCharging"):
|
||||
return Output{Code: 1, Stderr: "Unknown method"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func TestStatusReadsTheDriverTheGroupAndTheDaemonWithoutStartingIt(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Installed["openrazer-daemon"] != "3.12.4-1" || s.Driver.Built != "installed" || strings.Join(s.Driver.Loaded, ",") != "razermouse" ||
|
||||
len(s.Driver.Bound) != 1 || s.Driver.Bound[0] != (Bound{USB: "1532:00ab", Driver: "razermouse", Interfaces: 3}) {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if !s.Group.Exists || !s.Group.Account || s.Group.Manager == nil || !*s.Group.Manager {
|
||||
t.Fatalf("%+v", s.Group)
|
||||
}
|
||||
if s.Daemon.Version != "3.12.4" || len(s.Daemon.Running) != 1 || len(s.Devices) != 1 {
|
||||
t.Fatalf("%+v %+v", s.Daemon, s.Devices)
|
||||
}
|
||||
d := s.Devices[0]
|
||||
if d.Name != "Razer Basilisk V3 Pro" || d.Type != "mouse" || d.Battery == nil || *d.Battery != 85 || d.Charging != nil {
|
||||
t.Fatalf("%+v", d)
|
||||
}
|
||||
if len(s.Notes) != 1 || !strings.Contains(s.Notes[0], "plugdev") {
|
||||
t.Fatalf("%v", s.Notes)
|
||||
}
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, "busctl") && !strings.Contains(c, "--auto-start=no") {
|
||||
t.Fatalf("a bus call that could start the daemon: %s", c)
|
||||
}
|
||||
}
|
||||
raw, _ := json.Marshal(s)
|
||||
if strings.Contains(string(raw), serial) {
|
||||
t.Fatal("the answer carries a device's serial")
|
||||
}
|
||||
|
||||
stopped := newStack(t, false, false)
|
||||
s, _ = stopped.Status()
|
||||
if s.Group.Account || s.Group.Manager == nil || *s.Group.Manager || s.Daemon.Active != "failed" || stopped.called("busctl") {
|
||||
t.Fatalf("%+v %q", s, stopped.calls)
|
||||
}
|
||||
if len(s.Daemon.LastWords) != 1 || s.Daemon.LastWords[0] != "razer | CRITICAL | User is not a member of the openrazer group" {
|
||||
t.Fatalf("the reason, once, not the traceback: %q", s.Daemon.LastWords)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckPassesTheWholeStackAndNamesWhatIsMissing(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
c, err := f.Check()
|
||||
if err != nil || !c.OK || len(c.Starts) != 1 || !strings.HasPrefix(c.Starts[0], "D-Bus activation") {
|
||||
t.Fatalf("%+v %v", c, err)
|
||||
}
|
||||
|
||||
broken := newStack(t, false, false)
|
||||
broken.write("/proc/sys/kernel/osrelease", "7.3.0-arch1-1\n")
|
||||
broken.write(testHome+"/.config/i3/config", "exec --no-startup-id openrazer-daemon\n")
|
||||
c, _ = broken.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What+" => "+x.Do)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
for _, want := range []string{"not built for the running kernel 7.3.0-arch1-1", "not in the openrazer group", "gpasswd -a $USER openrazer",
|
||||
"a second start: ~/.config/i3/config:1", "failed at its last start: razer | CRITICAL | User is not a member"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("no finding %q in\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAGroupAddedAfterLoginIsNamedAsTheServiceManagers(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
f.write("/proc/3596/status", "Name:\tsystemd\nUid:\t1000\t1000\t1000\t1000\nGroups:\t970\n")
|
||||
c, _ := f.Check()
|
||||
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "service manager started before") {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADaemonOutsideItsUnitOrSeeingFewerDevicesIsAFinding(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
f.write("/proc/4269/cgroup", "0::/user.slice/user-1000.slice/session-c1.scope\n")
|
||||
f.write("/sys/bus/hid/drivers/razerkbd/0003:1532:0099.0001/x", "")
|
||||
c, _ := f.Check()
|
||||
var all []string
|
||||
for _, x := range c.Findings {
|
||||
all = append(all, x.What)
|
||||
}
|
||||
got := strings.Join(all, "\n")
|
||||
if !strings.Contains(got, "outside its unit (in session-c1.scope)") || !strings.Contains(got, "holds 2 device(s) and the daemon sees 1") {
|
||||
t.Fatalf("%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartGoesThroughTheUnitAndSaysWhyItFailed(t *testing.T) {
|
||||
f := newStack(t, true, true)
|
||||
a, err := f.Restart()
|
||||
if err != nil || a.Version != "3.12.4" || len(a.Devices) != 1 || !f.called("systemctl --user restart "+daemonUnit) {
|
||||
t.Fatalf("%+v %v %q", a, err, f.calls)
|
||||
}
|
||||
failing := newStack(t, false, false)
|
||||
inner := failing.answer
|
||||
failing.answer = func(name string, args []string) Output {
|
||||
if name == "systemctl" && len(args) > 1 && args[1] == "restart" {
|
||||
return Output{Code: 1, Stderr: "Job for openrazer-daemon.service failed"}
|
||||
}
|
||||
return inner(name, args)
|
||||
}
|
||||
if _, err := failing.Restart(); err == nil || !strings.Contains(err.Error(), "not a member of the openrazer group") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module openrazer
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"module": "openrazer",
|
||||
"version": "1",
|
||||
"capabilities": [
|
||||
"package-manager"
|
||||
],
|
||||
"tools": [
|
||||
"openrazer_status",
|
||||
"openrazer_restart",
|
||||
"openrazer_check"
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "driver",
|
||||
"type": "package",
|
||||
"package": "openrazer-driver-dkms"
|
||||
},
|
||||
{
|
||||
"id": "daemon",
|
||||
"type": "package",
|
||||
"package": "openrazer-daemon"
|
||||
},
|
||||
{
|
||||
"id": "library",
|
||||
"type": "package",
|
||||
"package": "python-openrazer"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/openrazer-tools",
|
||||
"binary": "openrazer-tools",
|
||||
"loads": [
|
||||
"openrazer-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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) | <ul><li>whether the tray runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and that it is from outside the official repositories</li><li>what starts it: the window-manager lines, and the helper while the setting is on</li><li>the helper's entry, and the tray setting with its delay</li><li>whether the openrazer daemon answers, and how many devices it has (asked with `--auto-start=no`: never starts it)</li></ul> |
|
||||
| `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) | <ul><li>the package is installed</li><li>exactly one start: one window-manager line; the helper does not start the tray too; no autostart entry of the account for it</li><li>one tray runs in a desktop session</li><li>the openrazer daemon answers</li></ul>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).
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module polychromatic
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -39,3 +39,12 @@ says so in its log.
|
||||
Search the mesh, through the console, for a phrase that appears only in one design document here, and
|
||||
get it back. `records_search {"query": "…"}` is that search; its test does the same against a
|
||||
repository it makes.
|
||||
|
||||
## Where the checkout lives
|
||||
|
||||
A Go bundle the node's runtime launches as the operator account (`cmd/records`). It clones into
|
||||
`repository/` inside the directory the mesh gives it — a directory it makes, and so owns. An earlier
|
||||
layout cloned into the given directory itself, from a container running as root, and left files the
|
||||
operator account cannot change; git then refused every sync as "dubious ownership" (hq issue 251). What
|
||||
that layout left is removed where it is the module's, and named in `records_status` (`leftBehind`, with
|
||||
the one command that deletes it) where it is not.
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// records: the mesh's record — decisions, designs and issues — read where it is written (novox/hq ADR 0025,
|
||||
// ADR 0153). A Go bundle the node's runtime launches; it keeps a checkout of one repository current on every
|
||||
// merge the forge announces and on a timer, and answers five questions about it. stdout is the MCP channel;
|
||||
// what this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
func readJSON(path string, into any) error {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw, into)
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func strArg(a map[string]any, k string) string { s, _ := a[k].(string); return s }
|
||||
|
||||
func tools(r *Records) []stdio.Tool {
|
||||
return []stdio.Tool{
|
||||
{Name: "records_search",
|
||||
Description: "Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
|
||||
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
|
||||
Input: map[string]any{
|
||||
"query": str("the phrase, matched case-insensitively as written"),
|
||||
"limit": map[string]any{"type": "number", "description": "at most this many places (default 50)"},
|
||||
},
|
||||
Run: func(a map[string]any) (any, error) {
|
||||
limit := 0
|
||||
if v, ok := a["limit"].(float64); ok {
|
||||
limit = int(v)
|
||||
}
|
||||
return r.Search(strArg(a, "query"), limit)
|
||||
}},
|
||||
{Name: "records_read",
|
||||
Description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
|
||||
Input: map[string]any{"path": str("the document's path, e.g. 02-DECISIONS/0025-....md")},
|
||||
Run: func(a map[string]any) (any, error) { return r.Read(strArg(a, "path")) }},
|
||||
{Name: "records_list",
|
||||
Description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
|
||||
Input: map[string]any{"folder": str("a folder inside the repository (optional)")},
|
||||
Run: func(a map[string]any) (any, error) { return r.List(strArg(a, "folder")) }},
|
||||
{Name: "records_status",
|
||||
Description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
|
||||
Run: func(map[string]any) (any, error) { return r.Standing(), nil }},
|
||||
{Name: "records_sync",
|
||||
Description: "Bring the checkout up to date now, and say where it stands.",
|
||||
Run: func(map[string]any) (any, error) {
|
||||
r.Sync()
|
||||
return r.Standing(), nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// keep keeps the checkout current: at start, on every merge the forge announces into this repository, and on
|
||||
// an unhurried timer for the merges it did not hear about — a restart during a merge, a repository the forge
|
||||
// does not emit for. The record changes when thinking changes, not by the minute.
|
||||
func keep(r *Records) {
|
||||
r.Sync()
|
||||
s := r.Standing()
|
||||
commit := s.Commit
|
||||
if len(commit) > 8 {
|
||||
commit = commit[:8]
|
||||
}
|
||||
line := fmt.Sprintf("[records] %s at %s, %d document(s)", s.Repository, commit, s.Documents)
|
||||
if s.LastError != "" {
|
||||
line += " — " + s.LastError
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, line)
|
||||
if s.LeftBehind != "" {
|
||||
fmt.Fprintln(os.Stderr, "[records] "+s.LeftBehind)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for range time.Tick(10 * time.Minute) {
|
||||
r.Sync()
|
||||
}
|
||||
}()
|
||||
|
||||
// A merge on the forge into the repository this reads: pull now. The event names the repository by
|
||||
// owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
|
||||
subscribe := func() error {
|
||||
return stdio.Subscribe("gitea.pull.merged", func(e stdio.Envelope) error {
|
||||
var body struct {
|
||||
Owner string `json:"owner"`
|
||||
Repo string `json:"repo"`
|
||||
}
|
||||
_ = json.Unmarshal(e.Body, &body)
|
||||
if body.Owner+"/"+body.Repo != r.Repository {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[records] %s merged; syncing\n", r.Repository)
|
||||
r.Sync()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
for wait := 2 * time.Second; ; wait = min(wait*2, time.Minute) {
|
||||
err := subscribe()
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(os.Stderr, "[records] hearing merges not yet (%v); asking again in %s\n", err, wait)
|
||||
time.Sleep(wait)
|
||||
}
|
||||
}
|
||||
|
||||
func main() {
|
||||
r, err := FromEnv(os.Getenv)
|
||||
if err != nil {
|
||||
// Without a repository to read there is nothing to answer: no tools rather than five that fail.
|
||||
fmt.Fprintf(os.Stderr, "[records] no tools — %v\n", err)
|
||||
if err := stdio.Serve("", nil); err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
go keep(r)
|
||||
if err := stdio.Serve("", tools(r)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
package main
|
||||
|
||||
// The record, read where it is written (novox/hq ADR 0025, ADR 0153).
|
||||
//
|
||||
// A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own forge
|
||||
// and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit every
|
||||
// answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
|
||||
// transformed, indexed or summarised on the way, so there is nothing that can drift from the source except
|
||||
// by lagging behind it, and the lag is a number in every answer.
|
||||
//
|
||||
// **The clone is in a directory this module made** (novox/hq issue 251). The module is given a directory;
|
||||
// it clones into `repository/` inside it, a directory it creates and so owns. The TypeScript module cloned
|
||||
// into the given directory itself, and a sync that once ran as another account left every file there that
|
||||
// account's: git refused the checkout as "dubious ownership" from then on, and the operator account the
|
||||
// module runs as could change none of it. What the old layout left is removed where it is this module's,
|
||||
// and named in the standing where it is not.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// MostHits is the most places a search answers; a phrase found more often is a phrase to narrow.
|
||||
const MostHits = 50
|
||||
|
||||
// MostBytes is how much of a document is answered; a longer one is answered in part, and says so.
|
||||
const MostBytes = 200_000
|
||||
|
||||
// Hit is one place a phrase was found.
|
||||
type Hit struct {
|
||||
// Path is the document, relative to the repository's root.
|
||||
Path string `json:"path"`
|
||||
// Line is the line it was found on, from 1.
|
||||
Line int `json:"line"`
|
||||
// Heading is the nearest heading above it, so a hit reads as where in the document it is.
|
||||
Heading string `json:"heading"`
|
||||
// Text is the line itself, trimmed.
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// Standing is where the checkout stands.
|
||||
type Standing struct {
|
||||
Repository string `json:"repository"`
|
||||
Origin string `json:"origin"`
|
||||
// Commit is the commit the checkout is at, or empty before the first clone.
|
||||
Commit string `json:"commit"`
|
||||
// Committed is when that commit was made, as the repository says.
|
||||
Committed string `json:"committed"`
|
||||
// Fetched is when this reader last brought the checkout up to date.
|
||||
Fetched string `json:"fetched"`
|
||||
// Documents is how many markdown documents the checkout holds.
|
||||
Documents int `json:"documents"`
|
||||
// LastError is why the last sync failed, if it did; the checkout stands where it was.
|
||||
LastError string `json:"lastError,omitempty"`
|
||||
// LeftBehind names what an earlier layout left in the module's directory that this account cannot
|
||||
// remove: another account's files, harmless, and the operator's to delete.
|
||||
LeftBehind string `json:"leftBehind,omitempty"`
|
||||
}
|
||||
|
||||
// Records reads one repository's checkout.
|
||||
type Records struct {
|
||||
// Base is the directory the mesh gives the module; the checkout is Base/repository.
|
||||
Base string
|
||||
// Origin is the forge's address, `scheme://host:port`, from the git provision's binding.
|
||||
Origin string
|
||||
// Repository is the repository's path on it, `owner/name`, from this module's settings.
|
||||
Repository string
|
||||
// URL overrides the clone URL; a test points it at a local repository.
|
||||
URL string
|
||||
|
||||
mu sync.Mutex
|
||||
syncing chan struct{}
|
||||
fetched string
|
||||
lastError string
|
||||
leftBehind string
|
||||
}
|
||||
|
||||
// Dir is where the checkout is.
|
||||
func (r *Records) Dir() string { return filepath.Join(r.Base, "repository") }
|
||||
|
||||
// CloneURL is the forge and the repository. Public repositories only; a credential would be a secret this
|
||||
// module has not asked for.
|
||||
func (r *Records) CloneURL() string {
|
||||
if r.URL != "" {
|
||||
return r.URL
|
||||
}
|
||||
return strings.TrimRight(r.Origin, "/") + "/" + r.Repository + ".git"
|
||||
}
|
||||
|
||||
func git(args ...string) (string, error) {
|
||||
cmd := exec.Command("git", args...)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), strings.TrimSpace(string(out)))
|
||||
}
|
||||
return strings.TrimSpace(string(out)), nil
|
||||
}
|
||||
|
||||
// Sync brings the checkout up to date, cloning it if it does not exist. One at a time: a call while one
|
||||
// runs waits for that one rather than racing it. It never fails: a failed sync is recorded in the standing
|
||||
// and the checkout stands where it was, which is still an answer.
|
||||
func (r *Records) Sync() {
|
||||
r.mu.Lock()
|
||||
if r.syncing != nil {
|
||||
wait := r.syncing
|
||||
r.mu.Unlock()
|
||||
<-wait
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
r.syncing = done
|
||||
r.mu.Unlock()
|
||||
|
||||
err := r.doSync()
|
||||
|
||||
r.mu.Lock()
|
||||
if err != nil {
|
||||
r.lastError = err.Error()
|
||||
fmt.Fprintf(os.Stderr, "[records] could not sync %s: %v\n", r.CloneURL(), err)
|
||||
} else {
|
||||
r.lastError = ""
|
||||
r.fetched = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
r.syncing = nil
|
||||
r.mu.Unlock()
|
||||
close(done)
|
||||
}
|
||||
|
||||
func (r *Records) doSync() error {
|
||||
r.clearOldLayout()
|
||||
dir := r.Dir()
|
||||
if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
|
||||
_ = os.RemoveAll(dir) // a clone that never finished
|
||||
if err := os.MkdirAll(r.Base, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := git("clone", "--quiet", "--depth", "50", r.CloneURL(), dir)
|
||||
return err
|
||||
}
|
||||
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has, rather than
|
||||
// merging into something a hand may have touched.
|
||||
if _, err := git("-C", dir, "fetch", "--quiet", "--depth", "50", "origin"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := git("-C", dir, "reset", "--quiet", "--hard", "origin/HEAD")
|
||||
return err
|
||||
}
|
||||
|
||||
// clearOldLayout removes what the TypeScript module's layout left in Base — a checkout in Base itself —
|
||||
// where it is this account's, and remembers what it is not.
|
||||
func (r *Records) clearOldLayout() {
|
||||
entries, err := os.ReadDir(r.Base)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
foreign := 0
|
||||
for _, e := range entries {
|
||||
if e.Name() == "repository" {
|
||||
continue
|
||||
}
|
||||
full := filepath.Join(r.Base, e.Name())
|
||||
if err := os.RemoveAll(full); err != nil {
|
||||
foreign++
|
||||
}
|
||||
}
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.leftBehind = ""
|
||||
if foreign > 0 {
|
||||
owner := "another account"
|
||||
if info, err := os.Stat(filepath.Join(r.Base, ".git")); err == nil {
|
||||
if st, ok := info.Sys().(*syscall.Stat_t); ok && st.Uid == 0 {
|
||||
owner = "root"
|
||||
}
|
||||
}
|
||||
r.leftBehind = fmt.Sprintf("%d entr(ies) of an earlier checkout in %s belong to %s and cannot be removed by this module; "+
|
||||
"they are not read — delete them once: sudo find %s -mindepth 1 -maxdepth 1 ! -name repository -exec rm -rf {} +",
|
||||
foreign, r.Base, owner, r.Base)
|
||||
}
|
||||
}
|
||||
|
||||
// Standing says where the checkout stands.
|
||||
func (r *Records) Standing() Standing {
|
||||
s := Standing{Repository: r.Repository, Origin: r.Origin}
|
||||
if _, err := os.Stat(filepath.Join(r.Dir(), ".git")); err == nil {
|
||||
s.Commit, _ = git("-C", r.Dir(), "rev-parse", "HEAD")
|
||||
s.Committed, _ = git("-C", r.Dir(), "log", "-1", "--format=%cI")
|
||||
}
|
||||
if s.Commit != "" {
|
||||
s.Documents = len(r.documents())
|
||||
}
|
||||
r.mu.Lock()
|
||||
s.Fetched, s.LastError, s.LeftBehind = r.fetched, r.lastError, r.leftBehind
|
||||
r.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
|
||||
// documents is every markdown document, relative to the root, in a stable order.
|
||||
func (r *Records) documents() []string {
|
||||
var out []string
|
||||
root := r.Dir()
|
||||
_ = filepath.WalkDir(root, func(full string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules") {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
if d.Type().IsRegular() && strings.HasSuffix(d.Name(), ".md") {
|
||||
rel, _ := filepath.Rel(root, full)
|
||||
out = append(out, filepath.ToSlash(rel))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
var (
|
||||
emphasis = regexp.MustCompile("[*_`]")
|
||||
spaces = regexp.MustCompile(`\s+`)
|
||||
heading = regexp.MustCompile(`^#{1,6}\s`)
|
||||
hashes = regexp.MustCompile(`^#+\s*`)
|
||||
)
|
||||
|
||||
// flat is a line as a phrase is matched against it: no emphasis marks, one space, lower case.
|
||||
func flat(line string) string {
|
||||
return strings.TrimSpace(spaces.ReplaceAllString(strings.ToLower(emphasis.ReplaceAllString(line, "")), " "))
|
||||
}
|
||||
|
||||
// SearchAnswer is what a search answers.
|
||||
type SearchAnswer struct {
|
||||
Hits []Hit `json:"hits"`
|
||||
More bool `json:"more"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Search finds where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
|
||||
// design record is found by its own words. Bounded, and says when it was.
|
||||
//
|
||||
// **The record is wrapped prose, and a phrase does not know where the line ends.** A line is matched
|
||||
// together with the one after it, joined by a space, and emphasis marks are ignored — `**reachable**` is
|
||||
// the word reachable. A hit names the line it starts on.
|
||||
func (r *Records) Search(query string, limit int) (SearchAnswer, error) {
|
||||
needle := flat(query)
|
||||
if needle == "" {
|
||||
return SearchAnswer{}, errors.New("search for a phrase; an empty one matches every line of every document")
|
||||
}
|
||||
if limit <= 0 || limit > MostHits {
|
||||
limit = MostHits
|
||||
}
|
||||
answer := SearchAnswer{Hits: []Hit{}}
|
||||
for _, p := range r.documents() {
|
||||
raw, err := os.ReadFile(filepath.Join(r.Dir(), filepath.FromSlash(p)))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
lines := strings.Split(string(raw), "\n")
|
||||
flats := make([]string, len(lines))
|
||||
for i, l := range lines {
|
||||
flats[i] = flat(l)
|
||||
}
|
||||
head := ""
|
||||
for i, line := range lines {
|
||||
if heading.MatchString(line) {
|
||||
head = strings.TrimSpace(hashes.ReplaceAllString(line, ""))
|
||||
}
|
||||
next := ""
|
||||
if i+1 < len(flats) {
|
||||
next = flats[i+1]
|
||||
}
|
||||
// On this line, or across the break into the next — but not a phrase that begins on the next
|
||||
// line alone, which is that line's hit.
|
||||
onThis := strings.Contains(flats[i], needle)
|
||||
across := !onThis && next != "" && strings.Contains(flats[i]+" "+next, needle) && !strings.Contains(next, needle)
|
||||
if !onThis && !across {
|
||||
continue
|
||||
}
|
||||
if len(answer.Hits) >= limit {
|
||||
answer.More = true
|
||||
break
|
||||
}
|
||||
text := strings.TrimSpace(line)
|
||||
if across {
|
||||
text += " " + strings.TrimSpace(lines[i+1])
|
||||
}
|
||||
answer.Hits = append(answer.Hits, Hit{Path: p, Line: i + 1, Heading: head, Text: text})
|
||||
}
|
||||
if answer.More {
|
||||
break
|
||||
}
|
||||
}
|
||||
answer.Commit = r.Standing().Commit
|
||||
return answer, nil
|
||||
}
|
||||
|
||||
// inside cleans a path given relative to the repository, refusing one that leaves it.
|
||||
func inside(p string) (string, bool) {
|
||||
clean := path.Clean(strings.ReplaceAll(p, "\\", "/"))
|
||||
if clean == "." {
|
||||
return "", true
|
||||
}
|
||||
if path.IsAbs(clean) || clean == ".." || strings.HasPrefix(clean, "../") {
|
||||
return "", false
|
||||
}
|
||||
return clean, true
|
||||
}
|
||||
|
||||
// Document is one document, as read answers it.
|
||||
type Document struct {
|
||||
Path string `json:"path"`
|
||||
Content string `json:"content"`
|
||||
Truncated bool `json:"truncated"`
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
|
||||
// Read is one document, whole, or its first part with a note when it is very long. The path stays inside
|
||||
// the checkout: `..` and absolute paths are refused, not resolved.
|
||||
func (r *Records) Read(p string) (Document, error) {
|
||||
clean, ok := inside(p)
|
||||
if !ok || clean == "" {
|
||||
return Document{}, fmt.Errorf("%q is not a path inside the repository", p)
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(r.Dir(), filepath.FromSlash(clean)))
|
||||
if err != nil {
|
||||
return Document{}, fmt.Errorf("the repository holds no %s — `records_list` says what it holds", clean)
|
||||
}
|
||||
d := Document{Path: clean, Content: string(raw), Commit: r.Standing().Commit}
|
||||
if len(d.Content) > MostBytes {
|
||||
d.Content = d.Content[:MostBytes] + "\n\n[… truncated; the document is longer than this answer carries]"
|
||||
d.Truncated = true
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
|
||||
// Folder is what a folder holds, one level.
|
||||
type Folder struct {
|
||||
Folder string `json:"folder"`
|
||||
Folders []string `json:"folders"`
|
||||
Documents []string `json:"documents"`
|
||||
}
|
||||
|
||||
// List says what a folder holds: its sub-folders and its documents.
|
||||
func (r *Records) List(folder string) (Folder, error) {
|
||||
clean, ok := inside(folder)
|
||||
if !ok {
|
||||
return Folder{}, fmt.Errorf("%q is not a folder inside the repository", folder)
|
||||
}
|
||||
entries, err := os.ReadDir(filepath.Join(r.Dir(), filepath.FromSlash(clean)))
|
||||
if err != nil {
|
||||
if clean == "" {
|
||||
clean = "/"
|
||||
}
|
||||
return Folder{}, fmt.Errorf("the repository holds no folder %s", clean)
|
||||
}
|
||||
out := Folder{Folder: clean, Folders: []string{}, Documents: []string{}}
|
||||
for _, e := range entries {
|
||||
switch {
|
||||
case e.IsDir() && e.Name() != ".git" && e.Name() != "node_modules":
|
||||
out.Folders = append(out.Folders, e.Name())
|
||||
case e.Type().IsRegular() && strings.HasSuffix(e.Name(), ".md"):
|
||||
out.Documents = append(out.Documents, e.Name())
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var repositoryName = regexp.MustCompile(`^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$`)
|
||||
|
||||
// FromEnv is the reader as the mesh configures it: the directory it was given, the forge it was bound to,
|
||||
// and the repository its settings name. It refuses to guess any of the three (novox/hq ADR 0112).
|
||||
func FromEnv(env func(string) string) (*Records, error) {
|
||||
dir := env("MESH_RECORDS_DIR")
|
||||
if dir == "" {
|
||||
return nil, errors.New("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in")
|
||||
}
|
||||
originFile := env("MESH_RECORDS_ORIGIN_FILE")
|
||||
if originFile == "" {
|
||||
return nil, errors.New("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding")
|
||||
}
|
||||
raw, err := os.ReadFile(originFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
origin := strings.TrimSpace(string(raw))
|
||||
if origin == "" {
|
||||
return nil, fmt.Errorf("%s is empty: the git provision has not been bound yet", originFile)
|
||||
}
|
||||
configFile := env("MESH_RECORDS_CONFIG_FILE")
|
||||
if configFile == "" {
|
||||
return nil, errors.New("MESH_RECORDS_CONFIG_FILE is unset")
|
||||
}
|
||||
var config struct {
|
||||
Repository any `json:"repository"`
|
||||
}
|
||||
if err := readJSON(configFile, &config); err != nil {
|
||||
return nil, fmt.Errorf("%s is not JSON: %w", configFile, err)
|
||||
}
|
||||
repository, _ := config.Repository.(string)
|
||||
repository = strings.TrimSpace(repository)
|
||||
if !repositoryName.MatchString(repository) {
|
||||
return nil, errors.New("this module reads the repository its settings name, and none is set: " +
|
||||
"`settings set records <file>` with {\"repository\": \"<owner>/<name>\"} — a module names no mesh (novox/hq ADR 0112)")
|
||||
}
|
||||
return &Records{Base: dir, Origin: origin, Repository: repository}, nil
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
// The reader against a real repository: a checkout, a phrase found where it is written, a document read
|
||||
// whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears only in
|
||||
// one design document, and get it back. Ported with the TypeScript module's tests, and the layout's own.
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func run(t *testing.T, dir string, args ...string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command("git", append([]string{"-C", dir}, args...)...)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("git %v: %s", args, out)
|
||||
}
|
||||
}
|
||||
|
||||
func write(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
_ = os.MkdirAll(filepath.Dir(path), 0o755)
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func aRepository(t *testing.T) string {
|
||||
dir := t.TempDir()
|
||||
run(t, dir, "init", "--quiet", "--initial-branch=main")
|
||||
run(t, dir, "config", "user.email", "t@example.invalid")
|
||||
run(t, dir, "config", "user.name", "t")
|
||||
write(t, filepath.Join(dir, "README.md"), "# A repository\n\nWhat this is.\n")
|
||||
write(t, filepath.Join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n")
|
||||
write(t, filepath.Join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\n"+
|
||||
"A phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n")
|
||||
run(t, dir, "add", "-A")
|
||||
run(t, dir, "commit", "--quiet", "-m", "first")
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestAPhraseInOneDesignDocumentComesBackFromWhereItIsWritten(t *testing.T) {
|
||||
origin := aRepository(t)
|
||||
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
|
||||
r.Sync()
|
||||
found, err := r.Search("never look alike", 0)
|
||||
if err != nil || len(found.Hits) != 1 || found.Hits[0].Path != "03-DESIGN/07-knowledge.md" || found.Hits[0].Heading != "The stores" {
|
||||
t.Fatalf("%+v %v", found, err)
|
||||
}
|
||||
if !regexp.MustCompile(`^[0-9a-f]{40}$`).MatchString(found.Commit) {
|
||||
t.Fatalf("the answer names no commit: %q", found.Commit)
|
||||
}
|
||||
// Wrapped prose: a phrase across the break is found once, at the line it starts on; emphasis is not
|
||||
// part of the words; a phrase on one line is not also counted from the line before it.
|
||||
for phrase, line := range map[string]int{"wrapped at the column where": 7, "reachable is not the same as surfacing": 8} {
|
||||
got, _ := r.Search(phrase, 0)
|
||||
if len(got.Hits) != 1 || got.Hits[0].Line != line {
|
||||
t.Errorf("%q: %+v", phrase, got.Hits)
|
||||
}
|
||||
}
|
||||
if got, _ := r.Search("under emphasis", 0); len(got.Hits) != 1 {
|
||||
t.Errorf("a phrase on one line was counted %d times", len(got.Hits))
|
||||
}
|
||||
doc, err := r.Read("02-DECISIONS/0001-a-decision.md")
|
||||
if err != nil || !strings.Contains(doc.Content, "We decided the thing") || doc.Truncated {
|
||||
t.Fatalf("%+v %v", doc, err)
|
||||
}
|
||||
root, _ := r.List("")
|
||||
if strings.Join(root.Folders, ",") != "02-DECISIONS,03-DESIGN" || strings.Join(root.Documents, ",") != "README.md" {
|
||||
t.Fatalf("%+v", root)
|
||||
}
|
||||
if s := r.Standing(); s.Documents != 3 || s.LastError != "" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
// A merge on the origin, pulled: the checkout follows and names the new commit.
|
||||
write(t, filepath.Join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n")
|
||||
run(t, origin, "add", "-A")
|
||||
run(t, origin, "commit", "--quiet", "-m", "second")
|
||||
r.Sync()
|
||||
after, _ := r.Search("nobody wrote before", 0)
|
||||
if len(after.Hits) != 1 || after.Commit == found.Commit {
|
||||
t.Fatalf("%+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPathOutsideTheRepositoryAndAnEmptySearchAreRefused(t *testing.T) {
|
||||
r := &Records{Base: t.TempDir(), Origin: "http://forge.invalid:3000", Repository: "novox/hq"}
|
||||
for _, p := range []string{"../etc/passwd", "/etc/passwd", "a/../../b"} {
|
||||
if _, err := r.Read(p); err == nil || !strings.Contains(err.Error(), "not a path inside") {
|
||||
t.Errorf("%s: %v", p, err)
|
||||
}
|
||||
}
|
||||
if _, err := r.Search(" ", 0); err == nil || !strings.Contains(err.Error(), "empty one") {
|
||||
t.Errorf("an empty search: %v", err)
|
||||
}
|
||||
if r.CloneURL() != "http://forge.invalid:3000/novox/hq.git" {
|
||||
t.Error(r.CloneURL())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedSyncLeavesTheCheckoutStandingAndSaysWhy(t *testing.T) {
|
||||
r := &Records{Base: t.TempDir(), Origin: "http://127.0.0.1:1", Repository: "novox/hq"}
|
||||
r.Sync()
|
||||
if s := r.Standing(); s.Commit != "" || s.LastError == "" {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
// The old layout — a checkout in the given directory itself — is cleared where it is this module's, and
|
||||
// the checkout is made in its own directory (novox/hq issue 251).
|
||||
func TestTheOldLayoutIsClearedAndTheCheckoutLivesInItsOwnDirectory(t *testing.T) {
|
||||
origin := aRepository(t)
|
||||
base := t.TempDir()
|
||||
write(t, filepath.Join(base, "README.md"), "an old checkout's file")
|
||||
_ = os.MkdirAll(filepath.Join(base, ".git"), 0o755)
|
||||
r := &Records{Base: base, Origin: "http://forge.invalid:3000", Repository: "novox/hq", URL: origin}
|
||||
r.Sync()
|
||||
if _, err := os.Stat(filepath.Join(base, "README.md")); err == nil {
|
||||
t.Error("the old layout's file was left")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(base, "repository", ".git")); err != nil {
|
||||
t.Fatal("the checkout is not in its own directory")
|
||||
}
|
||||
if s := r.Standing(); s.LeftBehind != "" || s.Documents != 3 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheReaderRefusesToGuess(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write(t, filepath.Join(dir, "origin"), "http://forge.invalid:3000\n")
|
||||
write(t, filepath.Join(dir, "config.json"), "{}\n")
|
||||
env := map[string]string{"MESH_RECORDS_DIR": dir, "MESH_RECORDS_ORIGIN_FILE": filepath.Join(dir, "origin"),
|
||||
"MESH_RECORDS_CONFIG_FILE": filepath.Join(dir, "config.json")}
|
||||
if _, err := FromEnv(func(k string) string { return env[k] }); err == nil || !strings.Contains(err.Error(), "none is set") {
|
||||
t.Fatalf("a module with no repository set guessed one: %v", err)
|
||||
}
|
||||
write(t, filepath.Join(dir, "config.json"), `{"repository": "novox/hq"}`)
|
||||
r, err := FromEnv(func(k string) string { return env[k] })
|
||||
if err != nil || r.Repository != "novox/hq" || r.Origin != "http://forge.invalid:3000" {
|
||||
t.Fatalf("%+v %v", r, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module records
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -1,34 +0,0 @@
|
||||
// records' consumer: keep the checkout current (novox/hq ADR 0153).
|
||||
//
|
||||
// Synced when the runtime binds the broker, on every merge the forge announces, and on a timer for
|
||||
// the merges it did not hear about — a restart during a merge, a repository the forge does not emit
|
||||
// for. The timer is unhurried: the record changes when thinking changes, not by the minute.
|
||||
import { on } from "@novox/mesh-sdk/events";
|
||||
import { recordsFromEnv, type Records } from "./records.js";
|
||||
|
||||
let records: Records | null = null;
|
||||
try {
|
||||
records = await recordsFromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[records] not reading — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
|
||||
if (records) {
|
||||
const reader = records;
|
||||
void reader.sync().then(async () => {
|
||||
const s = await reader.standing();
|
||||
console.log(`[records] ${s.repository} at ${s.commit.slice(0, 8) || "(no commit)"}, ${s.documents} document(s)${s.lastError ? ` — ${s.lastError}` : ""}`);
|
||||
});
|
||||
setInterval(() => void reader.sync(), 10 * 60 * 1000).unref();
|
||||
|
||||
// A merge on the forge into the repository this reads: pull now. The event names the repository
|
||||
// by owner and name (gitea's `pull.merged`); anything else is somebody else's merge.
|
||||
await on<{ owner?: string; repo?: string; base?: string }>("gitea.pull.merged", async (event) => {
|
||||
const merged = `${event.body.owner ?? ""}/${event.body.repo ?? ""}`;
|
||||
if (merged !== reader.repository) return;
|
||||
console.log(`[records] ${merged} merged; syncing`);
|
||||
await reader.sync();
|
||||
});
|
||||
}
|
||||
|
||||
export { records };
|
||||
@@ -2,9 +2,6 @@
|
||||
"module": "records",
|
||||
"version": "1",
|
||||
"slug": "records",
|
||||
"capabilities": [
|
||||
"container-runtime"
|
||||
],
|
||||
"requires": [
|
||||
"git"
|
||||
],
|
||||
@@ -60,14 +57,12 @@
|
||||
{
|
||||
"name": "code",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
],
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/records",
|
||||
"binary": "records",
|
||||
"loads": [
|
||||
"index.js",
|
||||
"tools/index.js"
|
||||
"records"
|
||||
],
|
||||
"env": {
|
||||
"MESH_RECORDS_CONFIG_FILE": "${dir:mesh-state}/config.json",
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-records",
|
||||
"version": "0.1.0",
|
||||
"description": "records — reads a repository of decisions, designs and issues where it is written, and answers questions about it (novox/hq ADR 0025, ADR 0153).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "tsc records.ts index.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
},
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
}
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
/**
|
||||
* The record, read where it is written (novox/hq ADR 0025, ADR 0153).
|
||||
*
|
||||
* A repository of decisions, designs and issues — markdown, nothing else — cloned from the mesh's own
|
||||
* forge and kept current. **A checkout, not a copy**: the same bytes the repository holds, at a commit
|
||||
* every answer names, refreshed on every merge the forge announces and on a timer besides. Nothing is
|
||||
* transformed, indexed or summarised on the way, so there is nothing that can drift from the source
|
||||
* except by lagging behind it, and the lag is a number in every answer.
|
||||
*
|
||||
* What it answers: a search for a phrase, a document by path, what a folder holds, and where the
|
||||
* checkout stands. The reasoning is in the documents; this only finds them.
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { join, normalize, relative, sep } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const run = promisify(execFile);
|
||||
|
||||
/** One place a phrase was found. */
|
||||
export interface Hit {
|
||||
/** The document, relative to the repository's root. */
|
||||
path: string;
|
||||
/** The line it was found on, from 1. */
|
||||
line: number;
|
||||
/** The nearest heading above it, so a hit reads as where in the document it is. */
|
||||
heading: string;
|
||||
/** The line itself, trimmed. */
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface Standing {
|
||||
repository: string;
|
||||
origin: string;
|
||||
/** The commit the checkout is at, or empty before the first clone. */
|
||||
commit: string;
|
||||
/** When that commit was made, as the repository says. */
|
||||
committed: string;
|
||||
/** When this reader last brought the checkout up to date. */
|
||||
fetched: string;
|
||||
/** How many markdown documents the checkout holds. */
|
||||
documents: number;
|
||||
/** Why the last sync failed, if it did; the checkout stands where it was. */
|
||||
lastError?: string;
|
||||
}
|
||||
|
||||
/** Search answers at most this many places; a phrase found more often is a phrase to narrow. */
|
||||
export const MOST_HITS = 50;
|
||||
/** A document longer than this is answered in part, and says so. */
|
||||
export const MOST_BYTES = 200_000;
|
||||
|
||||
export class Records {
|
||||
/** Where the checkout lives; the mesh gives the module the directory. */
|
||||
readonly dir: string;
|
||||
/** The forge's address, `scheme://host:port`, from the git provision's binding. */
|
||||
readonly origin: string;
|
||||
/** The repository's path on it, `owner/name`, from this module's settings. */
|
||||
readonly repository: string;
|
||||
|
||||
private syncing: Promise<void> | undefined;
|
||||
private fetched = "";
|
||||
private lastError: string | undefined;
|
||||
|
||||
constructor(dir: string, origin: string, repository: string) {
|
||||
this.dir = dir;
|
||||
this.origin = origin;
|
||||
this.repository = repository;
|
||||
}
|
||||
|
||||
/** The clone URL: the forge, the repository. Public repositories only; a credential would be a
|
||||
* secret this module has not asked for. */
|
||||
get url(): string {
|
||||
return `${this.origin.replace(/\/$/, "")}/${this.repository}.git`;
|
||||
}
|
||||
|
||||
/** Bring the checkout up to date, cloning it if it does not exist. One at a time: a second call
|
||||
* while one runs joins it rather than racing it. Never throws — a failed sync is recorded in
|
||||
* `standing()` and the checkout stands where it was, which is still an answer. */
|
||||
sync(): Promise<void> {
|
||||
if (!this.syncing) {
|
||||
this.syncing = this.doSync().finally(() => {
|
||||
this.syncing = undefined;
|
||||
});
|
||||
}
|
||||
return this.syncing;
|
||||
}
|
||||
|
||||
private async doSync(): Promise<void> {
|
||||
try {
|
||||
const cloned = await exists(join(this.dir, ".git"));
|
||||
if (!cloned) {
|
||||
await fs.mkdir(this.dir, { recursive: true });
|
||||
await run("git", ["clone", "--quiet", "--depth", "50", this.url, this.dir]);
|
||||
} else {
|
||||
// The checkout is the mesh's, so a local change is nobody's: reset to what the forge has,
|
||||
// rather than merging into something a hand may have touched.
|
||||
await run("git", ["-C", this.dir, "fetch", "--quiet", "--depth", "50", "origin"]);
|
||||
await run("git", ["-C", this.dir, "reset", "--quiet", "--hard", "origin/HEAD"]);
|
||||
}
|
||||
this.fetched = new Date().toISOString();
|
||||
this.lastError = undefined;
|
||||
} catch (e) {
|
||||
this.lastError = e instanceof Error ? e.message : String(e);
|
||||
console.error(`[records] could not sync ${this.url}: ${this.lastError}`);
|
||||
}
|
||||
}
|
||||
|
||||
async standing(): Promise<Standing> {
|
||||
let commit = "";
|
||||
let committed = "";
|
||||
if (await exists(join(this.dir, ".git"))) {
|
||||
try {
|
||||
commit = (await run("git", ["-C", this.dir, "rev-parse", "HEAD"])).stdout.trim();
|
||||
committed = (await run("git", ["-C", this.dir, "log", "-1", "--format=%cI"])).stdout.trim();
|
||||
} catch {
|
||||
// A checkout without a commit yet: said as empty rather than thrown.
|
||||
}
|
||||
}
|
||||
const documents = commit ? (await this.documents()).length : 0;
|
||||
return {
|
||||
repository: this.repository,
|
||||
origin: this.origin,
|
||||
commit,
|
||||
committed,
|
||||
fetched: this.fetched,
|
||||
documents,
|
||||
...(this.lastError ? { lastError: this.lastError } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Every markdown document, relative to the root, in a stable order. */
|
||||
async documents(): Promise<string[]> {
|
||||
const out: string[] = [];
|
||||
const walk = async (at: string): Promise<void> => {
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(at, { withFileTypes: true });
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name === ".git" || e.name === "node_modules") continue;
|
||||
const full = join(at, e.name);
|
||||
if (e.isDirectory()) await walk(full);
|
||||
else if (e.isFile() && e.name.endsWith(".md")) out.push(relative(this.dir, full).split(sep).join("/"));
|
||||
}
|
||||
};
|
||||
await walk(this.dir);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a phrase appears, case-insensitively, as written — no stemming, no ranking, because a
|
||||
* design record is found by its own words and a reader deciding which words matter would be a
|
||||
* second opinion about somebody else's document. Bounded, and says when it was.
|
||||
*
|
||||
* **The record is wrapped prose, and a phrase does not know where the line ends.** Every document
|
||||
* here wraps at a hundred columns, so a phrase of six words is as likely to straddle a line break
|
||||
* as not; matched line by line, the first live search for a sentence of ADR 0025 found nothing.
|
||||
* So a line is matched together with the one after it, joined by a space, and emphasis marks
|
||||
* are ignored — `**reachable**` is the word reachable. A hit still names the line it starts on.
|
||||
*/
|
||||
async search(query: string, limit = MOST_HITS): Promise<{ hits: Hit[]; more: boolean; commit: string }> {
|
||||
const needle = plain(query).toLowerCase().replace(/\s+/g, " ").trim();
|
||||
if (!needle) throw new Error("search for a phrase; an empty one matches every line of every document");
|
||||
const cap = Math.max(1, Math.min(limit, MOST_HITS));
|
||||
const hits: Hit[] = [];
|
||||
let more = false;
|
||||
for (const path of await this.documents()) {
|
||||
const text = await fs.readFile(join(this.dir, path), "utf8");
|
||||
let heading = "";
|
||||
const lines = text.split("\n");
|
||||
const flat = lines.map((l) => plain(l).toLowerCase().replace(/\s+/g, " ").trim());
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]!;
|
||||
if (/^#{1,6}\s/.test(line)) heading = line.replace(/^#+\s*/, "").trim();
|
||||
const here = flat[i]!;
|
||||
const next = i + 1 < flat.length ? flat[i + 1]! : "";
|
||||
// On this line, or across the break into the next — but not a phrase that begins on the
|
||||
// next line alone, which is that line's hit.
|
||||
const onThis = here.includes(needle);
|
||||
const acrossTheBreak = !onThis && next !== "" && `${here} ${next}`.includes(needle) && !next.includes(needle);
|
||||
if (onThis || acrossTheBreak) {
|
||||
if (hits.length >= cap) {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
hits.push({ path, line: i + 1, heading, text: acrossTheBreak ? `${line.trim()} ${lines[i + 1]!.trim()}` : line.trim() });
|
||||
}
|
||||
}
|
||||
if (more) break;
|
||||
}
|
||||
const commit = (await this.standing()).commit;
|
||||
return { hits, more, commit };
|
||||
}
|
||||
|
||||
/** One document, whole, or its first part with a note when it is very long. The path is kept
|
||||
* inside the checkout: `..` and absolute paths are refused, not resolved. */
|
||||
async read(path: string): Promise<{ path: string; content: string; truncated: boolean; commit: string }> {
|
||||
const clean = normalize(path).split(sep).join("/");
|
||||
if (!clean || clean.startsWith("..") || clean.startsWith("/") || clean.includes("/../")) {
|
||||
throw new Error(`"${path}" is not a path inside the repository`);
|
||||
}
|
||||
let content: string;
|
||||
try {
|
||||
content = await fs.readFile(join(this.dir, clean), "utf8");
|
||||
} catch {
|
||||
throw new Error(`the repository holds no ${clean} — \`records_list\` says what it holds`);
|
||||
}
|
||||
const truncated = content.length > MOST_BYTES;
|
||||
return {
|
||||
path: clean,
|
||||
content: truncated ? content.slice(0, MOST_BYTES) + "\n\n[… truncated; the document is longer than this answer carries]" : content,
|
||||
truncated,
|
||||
commit: (await this.standing()).commit,
|
||||
};
|
||||
}
|
||||
|
||||
/** What a folder holds: its sub-folders and its documents, one level. */
|
||||
async list(folder = ""): Promise<{ folder: string; folders: string[]; documents: string[] }> {
|
||||
const clean = normalize(folder || ".").split(sep).join("/").replace(/^\.\/?/, "");
|
||||
if (clean.startsWith("..") || clean.startsWith("/")) {
|
||||
throw new Error(`"${folder}" is not a folder inside the repository`);
|
||||
}
|
||||
const at = clean ? join(this.dir, clean) : this.dir;
|
||||
let entries: import("node:fs").Dirent[];
|
||||
try {
|
||||
entries = await fs.readdir(at, { withFileTypes: true });
|
||||
} catch {
|
||||
throw new Error(`the repository holds no folder ${clean || "/"}`);
|
||||
}
|
||||
const folders = entries.filter((e) => e.isDirectory() && e.name !== ".git" && e.name !== "node_modules").map((e) => e.name).sort();
|
||||
const documents = entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
|
||||
return { folder: clean, folders, documents };
|
||||
}
|
||||
}
|
||||
|
||||
/** The reader as the mesh configures it: the directory it was given, the forge it was bound to, and
|
||||
* the repository its settings name. Refuses to guess any of the three (novox/hq ADR 0112). */
|
||||
export async function recordsFromEnv(env: NodeJS.ProcessEnv = process.env): Promise<Records> {
|
||||
const dir = env.MESH_RECORDS_DIR;
|
||||
if (!dir) throw new Error("MESH_RECORDS_DIR is unset: the mesh gives this module the directory its checkout lives in");
|
||||
const originFile = env.MESH_RECORDS_ORIGIN_FILE;
|
||||
if (!originFile) throw new Error("MESH_RECORDS_ORIGIN_FILE is unset: the forge's address comes from the git provision's binding");
|
||||
const origin = (await fs.readFile(originFile, "utf8")).trim();
|
||||
if (!origin) throw new Error(`${originFile} is empty: the git provision has not been bound yet`);
|
||||
const configFile = env.MESH_RECORDS_CONFIG_FILE;
|
||||
if (!configFile) throw new Error("MESH_RECORDS_CONFIG_FILE is unset");
|
||||
let config: { repository?: unknown } = {};
|
||||
try {
|
||||
config = JSON.parse(await fs.readFile(configFile, "utf8")) as { repository?: unknown };
|
||||
} catch (e) {
|
||||
throw new Error(`${configFile} is not JSON: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
const repository = typeof config.repository === "string" ? config.repository.trim() : "";
|
||||
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
|
||||
throw new Error(
|
||||
"this module reads the repository its settings name, and none is set: " +
|
||||
'`settings set records <file>` with {"repository": "<owner>/<name>"} — a module names no mesh (novox/hq ADR 0112)',
|
||||
);
|
||||
}
|
||||
return new Records(dir, origin, repository);
|
||||
}
|
||||
|
||||
/** A line without its markdown emphasis, so a phrase matches the words and not the marks. */
|
||||
function plain(line: string): string {
|
||||
return line.replace(/[*_`]/g, "");
|
||||
}
|
||||
|
||||
async function exists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.stat(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* The reader against a real repository: a checkout, a phrase found where it is written, a document
|
||||
* read whole, a merge pulled — and the check novox/hq ADR 0025 names: search for a phrase that appears
|
||||
* only in one design document, and get it back.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { Records } from "../records.ts";
|
||||
|
||||
function aRepository(): string {
|
||||
const dir = mkdtempSync("/tmp/records-origin-");
|
||||
const git = (...args: string[]) => execFileSync("git", ["-C", dir, ...args], { stdio: "pipe" });
|
||||
git("init", "--quiet", "--initial-branch=main");
|
||||
git("config", "user.email", "t@example.invalid");
|
||||
git("config", "user.name", "t");
|
||||
mkdirSync(join(dir, "02-DECISIONS"));
|
||||
mkdirSync(join(dir, "03-DESIGN"));
|
||||
writeFileSync(join(dir, "README.md"), "# A repository\n\nWhat this is.\n");
|
||||
writeFileSync(join(dir, "02-DECISIONS/0001-a-decision.md"), "# 1. A decision\n\n## Context\n\nThe context.\n\n## Decision\n\nWe decided the thing.\n");
|
||||
writeFileSync(join(dir, "03-DESIGN/07-knowledge.md"), "# Knowledge\n\n## The stores\n\nSilence and success must never look alike.\n\nA phrase that is wrapped at the\ncolumn where every document wraps, and **reachable is not the same as\nsurfacing** under emphasis.\n");
|
||||
git("add", "-A");
|
||||
git("commit", "--quiet", "-m", "first");
|
||||
return dir;
|
||||
}
|
||||
|
||||
test("a phrase that appears in one design document comes back from where it is written", async () => {
|
||||
const origin = aRepository();
|
||||
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "file://" + origin.replace(/\/[^/]+$/, ""), origin.split("/").pop()!);
|
||||
// A file:// origin has no `.git` suffix; point the clone at the directory itself.
|
||||
Object.defineProperty(records, "url", { get: () => origin });
|
||||
await records.sync();
|
||||
const found = await records.search("never look alike");
|
||||
assert.equal(found.hits.length, 1);
|
||||
assert.equal(found.hits[0]!.path, "03-DESIGN/07-knowledge.md");
|
||||
assert.equal(found.hits[0]!.heading, "The stores");
|
||||
assert.match(found.commit, /^[0-9a-f]{40}$/, "the answer names the commit it was read at");
|
||||
|
||||
// Wrapped prose: a phrase across the line break is found, once, at the line it starts on; and
|
||||
// emphasis marks are not part of the words.
|
||||
const wrapped = await records.search("wrapped at the column where");
|
||||
assert.deepEqual(wrapped.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 7]]);
|
||||
const emphasised = await records.search("reachable is not the same as surfacing");
|
||||
assert.deepEqual(emphasised.hits.map((h) => [h.path, h.line]), [["03-DESIGN/07-knowledge.md", 8]]);
|
||||
const alsoOnOneLine = await records.search("under emphasis");
|
||||
assert.equal(alsoOnOneLine.hits.length, 1, "a phrase on one line is not also counted from the line before it");
|
||||
|
||||
const doc = await records.read("02-DECISIONS/0001-a-decision.md");
|
||||
assert.match(doc.content, /We decided the thing/);
|
||||
assert.equal(doc.truncated, false);
|
||||
|
||||
const root = await records.list();
|
||||
assert.deepEqual(root.folders, ["02-DECISIONS", "03-DESIGN"]);
|
||||
assert.deepEqual(root.documents, ["README.md"]);
|
||||
|
||||
const standing = await records.standing();
|
||||
assert.equal(standing.documents, 3);
|
||||
assert.equal(standing.lastError, undefined);
|
||||
|
||||
// A merge on the origin, pulled: the checkout follows the source and names the new commit.
|
||||
writeFileSync(join(origin, "02-DECISIONS/0002-another.md"), "# 2. Another\n\nA phrase nobody wrote before.\n");
|
||||
execFileSync("git", ["-C", origin, "add", "-A"], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", origin, "commit", "--quiet", "-m", "second"], { stdio: "pipe" });
|
||||
await records.sync();
|
||||
const after = await records.search("nobody wrote before");
|
||||
assert.equal(after.hits.length, 1);
|
||||
assert.notEqual(after.commit, found.commit);
|
||||
});
|
||||
|
||||
test("a path outside the repository is refused, and an empty search is too", async () => {
|
||||
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://forge.invalid:3000", "novox/hq");
|
||||
await assert.rejects(() => records.read("../etc/passwd"), /not a path inside/);
|
||||
await assert.rejects(() => records.read("/etc/passwd"), /not a path inside/);
|
||||
await assert.rejects(() => records.search(" "), /empty one/);
|
||||
assert.equal(records.url, "http://forge.invalid:3000/novox/hq.git");
|
||||
});
|
||||
|
||||
test("a sync that fails leaves the checkout standing and says why", async () => {
|
||||
const records = new Records(mkdtempSync("/tmp/records-checkout-"), "http://127.0.0.1:1", "novox/hq");
|
||||
await records.sync();
|
||||
const standing = await records.standing();
|
||||
assert.equal(standing.commit, "");
|
||||
assert.ok(standing.lastError, "a failed sync is said, not swallowed");
|
||||
});
|
||||
@@ -1,62 +0,0 @@
|
||||
// records' tools — how the record is asked (novox/hq ADR 0025, ADR 0153).
|
||||
//
|
||||
// Five questions, each answered from the checkout at the commit it names: where does a phrase
|
||||
// appear, what does one document say, what does a folder hold, where does the checkout stand, and
|
||||
// bring it up to date now. The reasoning stays in the documents; the tools only find them.
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { recordsFromEnv, type Records } from "../records.js";
|
||||
|
||||
export function getRecordsTools(records: Records): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "records_search",
|
||||
description:
|
||||
"Where a phrase appears in the decisions, designs and issues, as written: document, line, nearest heading. " +
|
||||
"Search the literal words of a symptom or a term before forming a hypothesis; the answer names the commit it was read at.",
|
||||
input: {
|
||||
query: { type: "string", description: "the phrase, matched case-insensitively as written" },
|
||||
limit: { type: "number", description: "at most this many places (default 50)" },
|
||||
},
|
||||
run: async (args) => records.search(String(args.query ?? ""), args.limit ? Number(args.limit) : undefined),
|
||||
},
|
||||
{
|
||||
name: "records_read",
|
||||
description: "One document, whole, by its path in the repository — a decision record, a design document, an issue report.",
|
||||
input: { path: { type: "string", description: "the document's path, e.g. 02-DECISIONS/0025-....md" } },
|
||||
run: async (args) => records.read(String(args.path ?? "")),
|
||||
},
|
||||
{
|
||||
name: "records_list",
|
||||
description: "What a folder of the repository holds: its sub-folders and its documents. The root when no folder is named.",
|
||||
input: { folder: { type: "string", description: "a folder inside the repository (optional)" } },
|
||||
run: async (args) => records.list(args.folder ? String(args.folder) : ""),
|
||||
},
|
||||
{
|
||||
name: "records_status",
|
||||
description: "Where the checkout stands: the repository, the forge it is read from, the commit and its date, when it was last brought up to date.",
|
||||
input: {},
|
||||
run: async () => records.standing(),
|
||||
},
|
||||
{
|
||||
name: "records_sync",
|
||||
description: "Bring the checkout up to date now, and say where it stands.",
|
||||
input: {},
|
||||
run: async () => {
|
||||
await records.sync();
|
||||
return records.standing();
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// The reader is made once, at load, from the environment the runtime resolves; the contributor is
|
||||
// synchronous and is called at every collection. Without a repository to read there is nothing to
|
||||
// answer, and the module exposes no tools rather than five that fail — the sdk's contract: a
|
||||
// contributor returning [] is normal.
|
||||
let reader: Records | null = null;
|
||||
try {
|
||||
reader = await recordsFromEnv();
|
||||
} catch (err) {
|
||||
console.log(`[records] no tools — ${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
registerModuleTools("records", () => (reader ? getRecordsTools(reader) : []));
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["records.ts", "index.ts", "tools/index.ts"]
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
"type": "file",
|
||||
"path": "/etc/resolv.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# For a machine where nothing else owns this file. On one where systemd-resolved\n# or NetworkManager does, assign that module instead \u2014 this one and those claim\n# the same thing, so the mesh refuses the pair rather than letting them take\n# turns overwriting each other, which is the failure this claim exists to stop.\n#\n# The mesh's resolver, and only it \u2014 the one line the predecessor wrote on every\n# machine it set up. It answers the mesh's names itself and forwards everything\n# else to upstreams named in its own configuration, never read from this file.\n# This file used to carry a second nameserver as a placeholder for \"whatever\n# this machine used before\"; that was never a fallback for names the mesh does\n# not know \u2014 a resolver's second line is asked only when the first does not\n# answer at all \u2014 and now that the first answers everything it would be a line\n# nothing ever reached.\nnameserver 127.0.0.1\noptions edns0\n"
|
||||
"content": "# Managed by the mesh.\n#\n# The machine's network manager is told to leave this file alone by the module\n# holding its uplink, which the mesh requires beside this one (novox/hq ADR 0117,\n# 0220): without it, the first change of network would rewrite the file.\n#\n# The mesh's one resolver first (novox/hq ADR 0194, 0196), by address — a machine\n# cannot resolve the name of the thing it resolves names with. It answers the\n# mesh's names itself and forwards every other name. A public resolver second,\n# asked only when the first does not answer at all — its machine or the tunnel\n# down, a captive portal holding the tunnel back — so public names keep\n# resolving then. An answer from the first, \"no such name\" included, is final,\n# so a mesh name is never asked of the public one while the mesh's answers. One\n# second and one attempt, so the wait before the fallback is short. Containers\n# copy these two lines from their machine.\nnameserver ${bound:wildcard-resolution:address}\nnameserver 1.1.1.1\noptions timeout:1 attempts:1 edns0\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"module": "resolved-split-dns",
|
||||
"version": "1",
|
||||
"slug": "splitdns",
|
||||
"requires": [
|
||||
"wildcard-resolution"
|
||||
],
|
||||
"claims": [
|
||||
{
|
||||
"name": "node-resolver-config",
|
||||
"scope": "node"
|
||||
}
|
||||
],
|
||||
"resources": [
|
||||
{
|
||||
"id": "drop-in",
|
||||
"type": "directory",
|
||||
"path": "/etc/systemd/resolved.conf.d",
|
||||
"mode": "0755"
|
||||
},
|
||||
{
|
||||
"id": "route",
|
||||
"type": "file",
|
||||
"path": "/etc/systemd/resolved.conf.d/mesh.conf",
|
||||
"mode": "0644",
|
||||
"content": "# Managed by the mesh.\n#\n# **Only the mesh's names.** The tilde makes this a routing domain rather than a\n# search domain: queries under it go to the resolver below, and everything else\n# keeps going wherever this machine already sent it. A resolver that took over\n# all of DNS would be this module claiming the machine's whole network, which\n# is not what it says it claims. The mesh's resolver can forward the rest too;\n# this module is for a machine that wants systemd-resolved to stay in charge of\n# that, and only lends it the mesh's suffix.\n#\n# 127.0.0.1 is where the mesh's resolver answers on every machine \u2014 a fixed\n# address, so this file needs to know nothing about this particular machine.\n# systemd-resolved holds .53 and .54 itself, which is why the resolver is on\n# neither, and why the two coexist here.\n[Resolve]\nDNS=127.0.0.1\nDomains=~internal\n"
|
||||
},
|
||||
{
|
||||
"id": "resolved",
|
||||
"type": "service",
|
||||
"unit": "systemd-resolved.service",
|
||||
"state": "running",
|
||||
"boot": "enabled",
|
||||
"restart-on": [
|
||||
"route"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
# slack
|
||||
|
||||
The Slack desktop app on the workstations, as a module (novox/hq ADR 0208). One Electron process is
|
||||
both Slack's window and its tray icon. The module requires `x11-display`, so it is assigned only where
|
||||
a display server is held on the same machine.
|
||||
|
||||
## Owns
|
||||
|
||||
| what | where |
|
||||
|---|---|
|
||||
| Slack's one start at login, and its window rules | a `config` contribution to `node-display-session` (ADR 0212), which the seat's holder (`i3`) places in its configuration |
|
||||
|
||||
Nothing else. It holds no seat and writes no file.
|
||||
|
||||
- **No package.** Both workstations run `slack-desktop` 4.51.191-1, installed explicitly, from the
|
||||
AUR: `pacman` counts it as foreign (no sync repository has it, and its packager is "Unknown
|
||||
Packager"). The host installs packages from the official repositories only, so the module does not
|
||||
declare it, and **installing and upgrading Slack stays the operator's** (an AUR helper, by hand).
|
||||
`slack_status` says whether it is outside the official repositories, and `slack_check` says when it
|
||||
is missing.
|
||||
- **Why not a pinned archive (ADR 0205):** Slack is a binary release of about 330 MB under Slack's own
|
||||
licence. ADR 0205 vendors free software the distribution lacks; redistributing Slack's binary from
|
||||
the mesh's store is not the module's to do. ADR 0205 does not apply, and the package stays as found.
|
||||
- **Slack's own files are found** (ADR 0182): `~/.config/Slack/` holds the sessions (cookies, local
|
||||
storage, the encryption key in `Local State`), the settings (`storage/root-state.json`), the caches
|
||||
and the logs. The module never declares or writes any of it. The tools read only the settings'
|
||||
switches and the logs, and never the session files.
|
||||
|
||||
## How it starts: the module's line in i3's configuration, and nothing else
|
||||
|
||||
One process has one starter (the rule `picom` states for the desktop modules). Slack's starter is
|
||||
**this module's contribution**: `exec --no-startup-id /usr/bin/slack --gtk-version=3 -s`, which `i3`
|
||||
runs once when the session starts. Those are the arguments of the package's own desktop entry. `-s`
|
||||
starts Slack hidden, to the tray.
|
||||
|
||||
**Why not an XDG autostart entry**, as `nextcloud-client` and `blueman` start:
|
||||
|
||||
- Slack's own setting *Launch app on login* is the existence of `~/.config/autostart/slack.desktop`.
|
||||
Slack reads the file's presence back into the setting at every start. Ticking the setting makes the
|
||||
file **a symbolic link** to `/usr/share/applications/slack.desktop`, and unticking it deletes the
|
||||
file. This is in Slack 4.51's own code, not a guess.
|
||||
- If the module owned that path as a file, two writers would hold it: the mesh writing it, and Slack
|
||||
deleting it whenever the setting is unticked.
|
||||
- If the entry were left to Slack, the start would be a link that Slack makes in the operator's home,
|
||||
which this mesh's rules do not want there.
|
||||
- A contribution is a start the mesh owns, beside the window rules it belongs with, in the window
|
||||
manager that runs it. Under sway, `sway` holds the same seat with the same grammar.
|
||||
|
||||
**Excluded, and named by `slack_check` as a second start:**
|
||||
|
||||
- any `~/.config/autostart/slack.desktop`: the predecessor's file, or Slack's link if *Launch app on
|
||||
login* is ticked again (untick it; Slack removes the link);
|
||||
- an `exec … slack` of the operator's in `~/.config/i3/config.d/`;
|
||||
- `/etc/xdg/autostart/slack.desktop`, which the package does not ship.
|
||||
|
||||
**Not a start:** systemd's XDG autostart generator makes a unit `app-slack@autostart.service` from the
|
||||
entry while the entry exists. Only a desktop that starts `xdg-desktop-autostart.target` runs it, and
|
||||
i3 does not. The unit goes when the entry goes.
|
||||
|
||||
**Slack's cgroup does not say who started it.** Slack moves its main process into a scope of its own
|
||||
(`app-slack-<pid>.scope`) whoever starts it. `slack_status` therefore names the starts from the
|
||||
configuration, not from the cgroup.
|
||||
|
||||
## The window rules
|
||||
|
||||
The operator's `~/.config/i3/config.d/50-slack.conf` (2026-08) became this contribution as it was:
|
||||
|
||||
- Slack's chat window goes to workspace 3 (`$ws3`, i3's variable, in scope where the contributions are
|
||||
placed);
|
||||
- it is tiled, not floating;
|
||||
- it has a 2-pixel border.
|
||||
|
||||
The criteria are those of the operator's file. They were verified live then, and the window tree of
|
||||
both workstations on 2026-10-05 still agrees:
|
||||
|
||||
- Slack owns four X windows. The only one i3 manages has the class `slack` in lower case.
|
||||
- The three of class `Slack` (the packaged entry's `StartupWMClass`) are unmanaged helpers and the tray
|
||||
icon. A rule on `class="Slack"` therefore matches nothing.
|
||||
- `window_role="browser-window"` keeps a call or screen-share window out of the rules.
|
||||
|
||||
**Once the module is assigned, the operator deletes `50-slack.conf`** (below). i3 accepts the same
|
||||
`for_window` twice, so nothing breaks while both are there. But the rules belong in one place, and
|
||||
`slack_check` names the file until it is gone.
|
||||
|
||||
## Tools
|
||||
|
||||
They are served by the node's runtime as the operator account (ADR 0175), and are read-only except
|
||||
`restart`. **No answer carries a token, a cookie, a message, or the name of a person, channel or
|
||||
workspace.** Accounts and workspaces are counted, never named.
|
||||
|
||||
| tool | does |
|
||||
|---|---|
|
||||
| `slack_status` (r) | <ul><li>whether Slack runs: the main process's pid, since and scope, and how many helper processes it has</li><li>the installed version, and whether it is outside the official repositories</li><li>where its output goes (fd 1 and 2): the journal, `/dev/null`, a pipe someone reads, or a pipe nobody reads</li><li>whether its icon sits in a tray, and whose (from the X window tree)</li><li>who owns the session bus's notification name (dunst)</li><li>the switches: launch on login, hide on start, run from the tray, the notification method, hardware acceleration; and the Electron version</li><li>how many accounts and workspaces it is signed in to since its last start, counted from the reports Slack logs</li><li>what starts it at login</li></ul> |
|
||||
| `slack_log` (r) | the last `lines` (default 100, at most 2000) of Slack's main-process log (`source: browser`, across its rotations) or of the web app's console (`source: webapp`). `problems: true` keeps `error` and `warn` entries. Tokens (`xox…`), the `d` cookie, anything shaped like a credential, notification and message text, and the names of people, channels and workspaces become `<hidden>`. Answers are capped at 256 KiB |
|
||||
| `slack_restart` (a) | ends Slack (SIGTERM, forced after 8 s) and starts `/usr/bin/slack --gtk-version=3 -s` in the operator's session. The start is a transient user unit `mesh-slack`, so it outlives the tools runtime, and its output goes to the journal. Answers the pids. Refused plainly when nobody is logged in to the desktop |
|
||||
| `slack_check` (r) | <ul><li>Slack is installed (if not: from the AUR, by the operator)</li><li>exactly one start: the module's line is in i3's configuration, with no autostart entry and no other exec</li><li>the window rules are placed, and no file of the operator's repeats them</li><li>one Slack runs in the desktop session</li><li>**its output is read**: a pipe nobody reads is the session's dead output (below)</li><li>its icon is in a tray (closing the window otherwise leaves it unreachable)</li><li>a notifier owns `org.freedesktop.Notifications`</li></ul>Each finding says what to do |
|
||||
|
||||
**The output check (EPIPE).** A session started before the `i3` module's login entry sent the
|
||||
session's output to the journal (`systemd-cat -t x-session`) gives every program it starts a stdout
|
||||
pipe whose reader is gone. An Electron app's write there fails with EPIPE, and an unhandled one is the
|
||||
"write EPIPE" dialog. Slack's own logger works around it: it silences its console output on EPIPE. Any
|
||||
other write still fails. So `slack_check` finds the pipe by its reader: it looks for a process of the
|
||||
account holding the pipe open for reading (`/proc/<pid>/fdinfo`). It names it when there is none.
|
||||
`slack_restart` cures it, and every later login does too.
|
||||
|
||||
**Where the tools read:**
|
||||
|
||||
- the processes, and the fd links and fdinfo of Slack's main process, in `/proc` (the account's own
|
||||
only);
|
||||
- the settings' switches, from `~/.config/Slack/storage/root-state.json`, and the Electron version from
|
||||
`local-settings.json`;
|
||||
- the logs in `~/.config/Slack/logs/default/`. The accounts are counted from the
|
||||
`STORE_USER_WORKSPACES` entries since the last `INITIALIZE`;
|
||||
- the window tree from `xwininfo -root -tree`, with the session's `DISPLAY`. The session is found from
|
||||
the window manager's own environment, as the other desktop modules find it;
|
||||
- the notifier from `busctl --user list`.
|
||||
|
||||
Every command has a timeout and capped output. Everything runs through an injected runner, a fake root
|
||||
and a fake link reader in the tests.
|
||||
|
||||
## What changes when it is assigned
|
||||
|
||||
| | g14 | shanks |
|
||||
|---|---|---|
|
||||
| package | none: `slack-desktop` 4.51.191-1, AUR, installed explicitly | the same |
|
||||
| start | i3's configuration gains the module's start. **Until the predecessor's `~/.config/autostart/slack.desktop` is deleted, the next login starts Slack twice.** The second start hands over to the first and exits, because Slack is single-instance. Running now: started by dex from that entry at the login of 2026-10-04 16:26 | the same entry, the same until deleted. Running now: since 2026-10-04 17:05, **with its stdout on a pipe nobody reads** (that session predates the `i3` module's login entry), so `slack_check` names it until `slack_restart` or the next login |
|
||||
| window rules | i3's configuration gains them. The operator's `50-slack.conf` repeats them until deleted | the same |
|
||||
| settings | *Launch app on login* reads on (the entry exists), start hidden, run from the tray, default notifications (to dunst); 1 account, 1 workspace | the same switches |
|
||||
| tray, notifications | icon in i3bar's tray; dunst owns the notification name | the same |
|
||||
|
||||
## Migration (ADR 0182), on each workstation, once the module is assigned and pushed
|
||||
|
||||
1. **Delete `~/.config/autostart/slack.desktop`.** It is the predecessor's file (its comment names the
|
||||
retired desktop module), the second start. At its next start Slack reads *Launch app on login* as
|
||||
off. Leave the setting off: ticking it makes the entry again, and `slack_check` names it.
|
||||
2. **Delete `~/.config/i3/config.d/50-slack.conf`.** It is the operator's own file, and its rules are
|
||||
now the module's.
|
||||
3. **shanks only:** run `slack_restart`, or log out and in, so that Slack's output is read.
|
||||
|
||||
`slack_check` then answers `ok`. Deleting both files before the module is assigned would leave Slack
|
||||
without a start and without its rules until it is.
|
||||
|
||||
## Leaves as found
|
||||
|
||||
- `~/.config/Slack/`: the sessions, the settings, the caches, the logs, more than a dozen
|
||||
`.org.chromium.Chromium.*` leftovers and `StaleCookies-*` files from past upgrades. They are Slack's
|
||||
to keep and the operator's to delete.
|
||||
- The package and its desktop entry, `/usr/share/applications/slack.desktop`.
|
||||
- The `x-scheme-handler/slack` default, which is the `xdg` module's.
|
||||
|
||||
## Relies on
|
||||
|
||||
- **`i3` (the holder of `node-display-session`), which places the contribution.** The contribution is a
|
||||
dependency on that seat (ADR 0210 §3). Assigning `slack` where no module holds it is refused.
|
||||
- **`dunst` (the holder of `node-notifier`)** for Slack's notifications, which Electron sends to
|
||||
`org.freedesktop.Notifications`. Nothing in the mesh declares this dependency, because the module
|
||||
makes no contribution to that seat. `slack_check` reports a missing notifier instead.
|
||||
- **A tray in the bar** (i3bar's `tray_output`). Without one, Slack runs with no icon, and closing its
|
||||
window leaves it unreachable. `slack_check` reports it.
|
||||
- **`xwininfo` (`xorg-xwininfo`)** for the tray question. No module declares it, and this one does not
|
||||
install it for a status line. Without it, `slack_status` answers the tray as unknown.
|
||||
- A display server on the same machine (`x11-display`, ADR 0208 §3).
|
||||
@@ -0,0 +1,97 @@
|
||||
// Reading a tool's arguments: JSON numbers arrive as float64, and a missing argument is its default.
|
||||
// The same in every desktop module that carries it.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// text is a string argument, trimmed; required says an empty one is refused.
|
||||
func text(args map[string]any, key string, required bool) (string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
if required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("%s is a string, not %T", key, v)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" && required {
|
||||
return "", fmt.Errorf("%s is required", key)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// whole is a whole-number argument within [least, most], or def when absent.
|
||||
func whole(args map[string]any, key string, def, least, most int) (int, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
if i, isInt := v.(int); isInt {
|
||||
f = float64(i)
|
||||
} else {
|
||||
return 0, fmt.Errorf("%s is a number, not %T", key, v)
|
||||
}
|
||||
}
|
||||
if f != math.Trunc(f) {
|
||||
return 0, fmt.Errorf("%s is a whole number, not %v", key, f)
|
||||
}
|
||||
n := int(f)
|
||||
if n < least || n > most {
|
||||
return 0, fmt.Errorf("%s is %d; it is between %d and %d", key, n, least, most)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// flag is a boolean argument, or def when absent.
|
||||
func flag(args map[string]any, key string, def bool) (bool, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return def, nil
|
||||
}
|
||||
b, ok := v.(bool)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("%s is true or false, not %T", key, v)
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// texts is a list-of-strings argument.
|
||||
func texts(args map[string]any, key string) ([]string, error) {
|
||||
v, present := args[key]
|
||||
if !present || v == nil {
|
||||
return nil, nil
|
||||
}
|
||||
list, ok := v.([]any)
|
||||
if !ok {
|
||||
if ss, isStrings := v.([]string); isStrings {
|
||||
return ss, nil
|
||||
}
|
||||
return nil, fmt.Errorf("%s is a list of strings, not %T", key, v)
|
||||
}
|
||||
out := make([]string, 0, len(list))
|
||||
for i, item := range list {
|
||||
s, ok := item.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s[%d] is a string, not %T", key, i, item)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// seconds is a timeout argument in seconds, defaulted and bounded below the runtime's call limit.
|
||||
func seconds(args map[string]any, key string, def, most int) (time.Duration, error) {
|
||||
n, err := whole(args, key, def, 1, most)
|
||||
return time.Duration(n) * time.Second, err
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package main
|
||||
|
||||
// The desktop applications whose bundles carry desktop.go. Each builds alone, so each has its own copy;
|
||||
// this test, itself one of the copied files, holds them to one text wherever the siblings are present.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var carriers = []string{"blueman", "forticlient", "nextcloud-client", "nm-applet", "openrazer", "polychromatic", "slack"}
|
||||
|
||||
func TestEveryDesktopApplicationCarriesTheSameCopy(t *testing.T) {
|
||||
compared := 0
|
||||
for _, module := range carriers {
|
||||
dir := filepath.Join("..", "..", "..", module, "cmd", module+"-tools")
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
continue
|
||||
}
|
||||
for _, f := range []string{"desktop.go", "desktop_test.go", "copies_test.go"} {
|
||||
mine, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
theirs, err := os.ReadFile(filepath.Join(dir, f))
|
||||
if err != nil || !bytes.Equal(mine, theirs) {
|
||||
t.Errorf("%s's copy of %s differs from this one: change every copy together", module, f)
|
||||
}
|
||||
}
|
||||
compared++
|
||||
}
|
||||
if compared == 0 {
|
||||
t.Log("no sibling copies beside this module")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,601 @@
|
||||
package main
|
||||
|
||||
// desktop.go is the same file in every desktop application's bundle (copies_test.go names them and
|
||||
// holds them to one text): a tray application of the operator's graphical session, seen from the
|
||||
// node's tool runtime (novox/hq ADR 0208).
|
||||
//
|
||||
// The runtime is a system service running as the operator account (ADR 0175): it has the account's
|
||||
// uid and none of the session's environment. A tool that starts something on the desktop finds the
|
||||
// session from a process of the account that carries DISPLAY (the window manager first), and starts
|
||||
// the program under the account's own service manager with `systemd-run --user`, never as its own
|
||||
// child: the runtime's unit is a cgroup that is emptied whenever the runtime restarts.
|
||||
//
|
||||
// Everything a tool touches goes through a Machine: its filesystem root, its commands (a Runner) and
|
||||
// its signals are injected, so the tests run against a fake /proc and a fake home.
|
||||
//
|
||||
// Bounds: one command gets at most CallTimeout (below the runtime's 30 s call limit) and is ended
|
||||
// with everything it started when it takes longer; each stream is kept to MostOutput; a file is read
|
||||
// to at most MostRead.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Bounds every command and read is held to.
|
||||
const (
|
||||
CallTimeout = 10 * time.Second
|
||||
MostOutput = 256 << 10
|
||||
MostRead = 16 << 20
|
||||
)
|
||||
|
||||
// Output is what a command did.
|
||||
type Output struct {
|
||||
Stdout string
|
||||
Stderr string
|
||||
Code int
|
||||
// Err is why it did not run to an answer: not installed, ended on its timeout, or the spawn error.
|
||||
Err error
|
||||
Cut bool
|
||||
}
|
||||
|
||||
// ErrNotInstalled and ErrTimedOut are what a Runner answers in Output.Err.
|
||||
var (
|
||||
ErrNotInstalled = errors.New("not installed")
|
||||
ErrTimedOut = errors.New("timed out")
|
||||
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
|
||||
ErrNoSession = errors.New("no graphical session")
|
||||
)
|
||||
|
||||
// Runner runs one command with extra environment, within the context's deadline. Tests replace it.
|
||||
type Runner func(ctx context.Context, env []string, name string, args ...string) Output
|
||||
|
||||
// Machine is what the tools read and act on.
|
||||
type Machine struct {
|
||||
Root string // "" on the machine; a fake root in tests
|
||||
Home string // the operator's home, as the machine names it
|
||||
UID int
|
||||
Run Runner
|
||||
Kill func(pid int, sig syscall.Signal) error
|
||||
Sleep func(time.Duration)
|
||||
Now func() time.Time
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// NewMachine is the machine the bundle runs on.
|
||||
func NewMachine() *Machine {
|
||||
return &Machine{Home: operatorHome(), UID: os.Getuid(), Run: execRun, Kill: syscall.Kill,
|
||||
Sleep: time.Sleep, Now: time.Now, Timeout: CallTimeout}
|
||||
}
|
||||
|
||||
// operatorHome is the account's home: what the runtime was told, else the process's own.
|
||||
func operatorHome() string {
|
||||
if h := strings.TrimSpace(os.Getenv("MESH_OPERATOR_HOME")); h != "" {
|
||||
return h
|
||||
}
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func (m *Machine) path(p string) string { return filepath.Join(m.Root, p) }
|
||||
|
||||
// home is a path under the operator's home, on this machine's filesystem.
|
||||
func (m *Machine) home(rel ...string) string {
|
||||
return filepath.Join(append([]string{m.Root, m.Home}, rel...)...)
|
||||
}
|
||||
|
||||
// tilde shows a path under the home as ~/…, so an answer does not carry the account's name.
|
||||
func (m *Machine) tilde(p string) string {
|
||||
if m.Home != "" && m.Home != "/" {
|
||||
h := strings.TrimSuffix(m.Home, "/")
|
||||
if p == h {
|
||||
return "~"
|
||||
}
|
||||
if strings.HasPrefix(p, h+"/") {
|
||||
return "~/" + strings.TrimPrefix(p, h+"/")
|
||||
}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// cmd runs a command within the machine's timeout (or a shorter one).
|
||||
func (m *Machine) cmd(timeout time.Duration, env []string, name string, args ...string) Output {
|
||||
if timeout <= 0 || timeout > m.Timeout {
|
||||
timeout = m.Timeout
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
return m.Run(ctx, env, name, args...)
|
||||
}
|
||||
|
||||
// failed names how a command failed, or answers nil when it ran and exited 0.
|
||||
func failed(o Output, name string, args ...string) error {
|
||||
switch {
|
||||
case errors.Is(o.Err, ErrNotInstalled):
|
||||
return fmt.Errorf("%s is not installed on this machine", name)
|
||||
case errors.Is(o.Err, ErrTimedOut):
|
||||
return fmt.Errorf("%s gave no answer in time and was ended", name)
|
||||
case o.Err != nil:
|
||||
return fmt.Errorf("%s did not run: %v", name, o.Err)
|
||||
case o.Code != 0:
|
||||
said := strings.TrimSpace(o.Stderr)
|
||||
if said == "" {
|
||||
said = strings.TrimSpace(o.Stdout)
|
||||
}
|
||||
if said == "" {
|
||||
said = "and said nothing"
|
||||
}
|
||||
return fmt.Errorf("%s %s exited %d: %s", name, strings.Join(args, " "), o.Code, tail(said, 1000))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func tail(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return "…" + s[len(s)-n:]
|
||||
}
|
||||
|
||||
type capped struct {
|
||||
b bytes.Buffer
|
||||
cut bool
|
||||
}
|
||||
|
||||
func (c *capped) Write(p []byte) (int, error) {
|
||||
if room := MostOutput - c.b.Len(); room < len(p) {
|
||||
if room > 0 {
|
||||
c.b.Write(p[:room])
|
||||
}
|
||||
c.cut = true
|
||||
return len(p), nil
|
||||
}
|
||||
return c.b.Write(p)
|
||||
}
|
||||
|
||||
func execRun(ctx context.Context, env []string, name string, args ...string) Output {
|
||||
path, err := exec.LookPath(name)
|
||||
if err != nil {
|
||||
return Output{Code: 127, Err: ErrNotInstalled}
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, path, args...)
|
||||
cmd.Env = append(append(os.Environ(), "LC_ALL=C"), env...)
|
||||
// Its own process group, so that ending it on a timeout ends what it started too.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
if cmd.Process != nil {
|
||||
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
cmd.WaitDelay = 2 * time.Second
|
||||
var out, errs capped
|
||||
cmd.Stdout, cmd.Stderr = &out, &errs
|
||||
err = cmd.Run()
|
||||
o := Output{Stdout: out.b.String(), Stderr: errs.b.String(), Cut: out.cut || errs.cut}
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case err == nil:
|
||||
case ctx.Err() == context.DeadlineExceeded:
|
||||
o.Code, o.Err = 124, ErrTimedOut
|
||||
case errors.As(err, &exit):
|
||||
o.Code = exit.ExitCode()
|
||||
default:
|
||||
o.Code, o.Err = 127, err
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// readBounded reads a file to at most MostRead bytes.
|
||||
func readBounded(path string) ([]byte, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
return io.ReadAll(io.LimitReader(f, MostRead))
|
||||
}
|
||||
|
||||
// Proc is one process of the account.
|
||||
type Proc struct {
|
||||
PID int `json:"pid"`
|
||||
Command string `json:"command"`
|
||||
// StartedIn is the unit or scope it runs in: the login session's scope when the session's start
|
||||
// (dex, the window manager) started it, a mesh-… unit when a tool restarted it.
|
||||
StartedIn string `json:"started_in,omitempty"`
|
||||
Since string `json:"since,omitempty"`
|
||||
}
|
||||
|
||||
// procs are this account's processes named comm, oldest first.
|
||||
func (m *Machine) procs(comm string) []Proc {
|
||||
entries, err := os.ReadDir(m.path("/proc"))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
boot := m.bootTime()
|
||||
var out []Proc
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if readTrimmed(filepath.Join(dir, "comm")) != comm || m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
p := Proc{PID: pid, Command: strings.TrimSpace(strings.ReplaceAll(readTrimmed(filepath.Join(dir, "cmdline")), "\x00", " "))}
|
||||
if p.Command == "" {
|
||||
p.Command = comm
|
||||
}
|
||||
if cg := readTrimmed(filepath.Join(dir, "cgroup")); cg != "" {
|
||||
line := strings.Split(cg, "\n")[0]
|
||||
p.StartedIn = filepath.Base(line[strings.LastIndexByte(line, ':')+1:])
|
||||
}
|
||||
if t, ok := startOf(readTrimmed(filepath.Join(dir, "stat")), boot); ok {
|
||||
p.Since = t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].PID < out[j].PID })
|
||||
return out
|
||||
}
|
||||
|
||||
// procsOf are the account's processes named comm whose program is word. The kernel keeps 15
|
||||
// characters of a command name, so a longer name can share them with another program's: this bundle's
|
||||
// own binary among them (polychromatic-tools and polychromatic-tray-applet are both polychromatic-t).
|
||||
// The program is the first word of the command line, or the second for a script run by its
|
||||
// interpreter. An empty word keeps every process named comm.
|
||||
func (m *Machine) procsOf(comm, word string) []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(comm) {
|
||||
f := strings.Fields(p.Command)
|
||||
if word == "" || (len(f) > 0 && filepath.Base(f[0]) == word) || (len(f) > 1 && filepath.Base(f[1]) == word) {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// uidOf is the real uid on a process's status, -1 when unreadable.
|
||||
func (m *Machine) uidOf(dir string) int {
|
||||
for _, l := range strings.Split(readTrimmed(filepath.Join(dir, "status")), "\n") {
|
||||
if f := strings.Fields(l); len(f) > 1 && f[0] == "Uid:" {
|
||||
if n, err := strconv.Atoi(f[1]); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func (m *Machine) bootTime() int64 {
|
||||
for _, l := range strings.Split(readTrimmed(m.path("/proc/stat")), "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "btime" {
|
||||
n, _ := strconv.ParseInt(f[1], 10, 64)
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// startOf reads a process's start from its stat line (field 22, in clock ticks of 1/100 s since boot).
|
||||
func startOf(stat string, boot int64) (time.Time, bool) {
|
||||
i := strings.LastIndexByte(stat, ')')
|
||||
if i < 0 || boot == 0 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
f := strings.Fields(stat[i+1:])
|
||||
if len(f) < 20 {
|
||||
return time.Time{}, false
|
||||
}
|
||||
ticks, err := strconv.ParseInt(f[19], 10, 64)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return time.Unix(boot+ticks/100, 0), true
|
||||
}
|
||||
|
||||
func readTrimmed(path string) string {
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func exists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Session is what a tool needs to start something on the operator's desktop.
|
||||
type Session struct {
|
||||
Display string `json:"display"`
|
||||
XAuthority string `json:"xauthority,omitempty"`
|
||||
Bus string `json:"bus,omitempty"`
|
||||
RuntimeDir string `json:"runtime_dir,omitempty"`
|
||||
From string `json:"found_in"`
|
||||
}
|
||||
|
||||
// sessionHolders are the processes whose environment is the session's, best first.
|
||||
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "dunst", "xterm"}
|
||||
|
||||
// session finds the account's graphical session, or ErrNoSession saying what it looked at.
|
||||
func (m *Machine) session() (Session, error) {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
best, bestRank := -1, len(sessionHolders)+1
|
||||
var env map[string]string
|
||||
var from string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
vars := parseEnviron(raw)
|
||||
if vars["DISPLAY"] == "" {
|
||||
continue
|
||||
}
|
||||
comm := readTrimmed(filepath.Join(dir, "comm"))
|
||||
rank := len(sessionHolders)
|
||||
for i, h := range sessionHolders {
|
||||
if h == comm {
|
||||
rank = i
|
||||
}
|
||||
}
|
||||
if rank < bestRank || (rank == bestRank && pid > best) {
|
||||
best, bestRank, env, from = pid, rank, vars, fmt.Sprintf("process %s (pid %d)", comm, pid)
|
||||
}
|
||||
}
|
||||
if env == nil {
|
||||
return Session{}, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY. "+
|
||||
"Is anyone logged in to the desktop?", ErrNoSession, m.UID)
|
||||
}
|
||||
s := Session{Display: env["DISPLAY"], XAuthority: env["XAUTHORITY"], Bus: env["DBUS_SESSION_BUS_ADDRESS"],
|
||||
RuntimeDir: env["XDG_RUNTIME_DIR"], From: from}
|
||||
if s.RuntimeDir == "" {
|
||||
s.RuntimeDir = fmt.Sprintf("/run/user/%d", m.UID)
|
||||
}
|
||||
if s.Bus == "" && exists(m.path(filepath.Join(s.RuntimeDir, "bus"))) {
|
||||
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// bus is the account's session bus environment, which a logged-in account has with or without a
|
||||
// desktop: what a command needs to reach the user's service manager or a bus name.
|
||||
func (m *Machine) bus() []string {
|
||||
runtime := fmt.Sprintf("/run/user/%d", m.UID)
|
||||
return []string{"XDG_RUNTIME_DIR=" + runtime, "DBUS_SESSION_BUS_ADDRESS=unix:path=" + runtime + "/bus"}
|
||||
}
|
||||
|
||||
// Env is the session's variables, for a command that draws or speaks to the desktop.
|
||||
func (s Session) Env() []string {
|
||||
var env []string
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
|
||||
{"DBUS_SESSION_BUS_ADDRESS", s.Bus}, {"XDG_RUNTIME_DIR", s.RuntimeDir}} {
|
||||
if kv[1] != "" {
|
||||
env = append(env, kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
func parseEnviron(raw []byte) map[string]string {
|
||||
env := map[string]string{}
|
||||
for _, kv := range bytes.Split(raw, []byte{0}) {
|
||||
if i := bytes.IndexByte(kv, '='); i > 0 {
|
||||
env[string(kv[:i])] = string(kv[i+1:])
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// detach starts a long-lived program under the account's service manager, as a transient unit that
|
||||
// carries the session's display. A unit left by an earlier start under the same name is stopped
|
||||
// first, so the fixed name means at most one.
|
||||
func (m *Machine) detach(s Session, unit string, argv ...string) error {
|
||||
_ = m.cmd(5*time.Second, s.Env(), "systemctl", "--user", "stop", unit+".service")
|
||||
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
|
||||
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority}} {
|
||||
if kv[1] != "" {
|
||||
call = append(call, "--setenv="+kv[0]+"="+kv[1])
|
||||
}
|
||||
}
|
||||
call = append(append(call, "--"), argv...)
|
||||
return failed(m.cmd(8*time.Second, s.Env(), "systemd-run", call...), "systemd-run", call...)
|
||||
}
|
||||
|
||||
// stop ends every process of the account named in comms: SIGTERM, then SIGKILL for what is still
|
||||
// there after grace. It answers the pids that ended and those that had to be killed.
|
||||
func (m *Machine) stop(grace time.Duration, comms ...string) (ended, killed []int) {
|
||||
var ps []Proc
|
||||
for _, c := range comms {
|
||||
ps = append(ps, m.procs(c)...)
|
||||
}
|
||||
return m.stopProcs(grace, ps)
|
||||
}
|
||||
|
||||
// stopProcs ends the processes given, as stop does.
|
||||
func (m *Machine) stopProcs(grace time.Duration, ps []Proc) (ended, killed []int) {
|
||||
var pids []int
|
||||
for _, p := range ps {
|
||||
if m.Kill(p.PID, syscall.SIGTERM) == nil {
|
||||
pids = append(pids, p.PID)
|
||||
}
|
||||
}
|
||||
alive := func() []int {
|
||||
var left []int
|
||||
for _, pid := range pids {
|
||||
if exists(m.path(filepath.Join("/proc", strconv.Itoa(pid)))) {
|
||||
left = append(left, pid)
|
||||
}
|
||||
}
|
||||
return left
|
||||
}
|
||||
step := 200 * time.Millisecond
|
||||
for waited := time.Duration(0); waited < grace && len(alive()) > 0; waited += step {
|
||||
m.Sleep(step)
|
||||
}
|
||||
left := alive()
|
||||
for _, pid := range left {
|
||||
if m.Kill(pid, syscall.SIGKILL) == nil {
|
||||
killed = append(killed, pid)
|
||||
}
|
||||
}
|
||||
gone := map[int]bool{}
|
||||
for _, pid := range left {
|
||||
gone[pid] = true
|
||||
}
|
||||
for _, pid := range pids {
|
||||
if !gone[pid] {
|
||||
ended = append(ended, pid)
|
||||
}
|
||||
}
|
||||
return ended, killed
|
||||
}
|
||||
|
||||
// waitFor waits up to d for a process of the account named comm, and answers what it found.
|
||||
func (m *Machine) waitFor(comm string, d time.Duration) []Proc { return m.waitForOf(comm, "", d) }
|
||||
|
||||
// waitForOf waits up to d for a process of the account named comm whose program is word (procsOf).
|
||||
func (m *Machine) waitForOf(comm, word string, d time.Duration) []Proc {
|
||||
step := 250 * time.Millisecond
|
||||
for waited := time.Duration(0); ; waited += step {
|
||||
if p := m.procsOf(comm, word); len(p) > 0 || waited >= d {
|
||||
return p
|
||||
}
|
||||
m.Sleep(step)
|
||||
}
|
||||
}
|
||||
|
||||
// desktopEntry reads the [Desktop Entry] group of an XDG desktop file; nil when there is none.
|
||||
func desktopEntry(path string) map[string]string {
|
||||
raw, err := readBounded(path)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
in := false
|
||||
s := bufio.NewScanner(bytes.NewReader(raw))
|
||||
for s.Scan() {
|
||||
l := strings.TrimSpace(s.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(l, "["):
|
||||
in = l == "[Desktop Entry]"
|
||||
case in && l != "" && !strings.HasPrefix(l, "#"):
|
||||
if i := strings.IndexByte(l, '='); i > 0 {
|
||||
out[strings.TrimSpace(l[:i])] = strings.TrimSpace(l[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Autostart is what XDG autostart does with one entry: the account's file overrides the system's
|
||||
// of the same name, and Hidden=true (or the GNOME switch off) means it is not started.
|
||||
type Autostart struct {
|
||||
Entry string `json:"entry"`
|
||||
From string `json:"from"`
|
||||
Exec string `json:"exec,omitempty"`
|
||||
Starts bool `json:"starts"`
|
||||
Because string `json:"because,omitempty"`
|
||||
}
|
||||
|
||||
// autostart resolves one XDG autostart entry by its file name, the account's directory first.
|
||||
func (m *Machine) autostart(name string) Autostart {
|
||||
a := Autostart{Entry: name}
|
||||
user := m.home(".config", "autostart", name)
|
||||
system := m.path(filepath.Join("/etc/xdg/autostart", name))
|
||||
var e map[string]string
|
||||
switch {
|
||||
case exists(user):
|
||||
e, a.From = desktopEntry(user), m.tilde(filepath.Join(m.Home, ".config/autostart", name))
|
||||
case exists(system):
|
||||
e, a.From = desktopEntry(system), filepath.Join("/etc/xdg/autostart", name)
|
||||
default:
|
||||
a.Because = "no such entry in ~/.config/autostart or /etc/xdg/autostart"
|
||||
return a
|
||||
}
|
||||
a.Exec = e["Exec"]
|
||||
switch {
|
||||
case strings.EqualFold(e["Hidden"], "true"):
|
||||
a.Because = "Hidden=true"
|
||||
case strings.EqualFold(e["X-GNOME-Autostart-enabled"], "false"):
|
||||
a.Because = "X-GNOME-Autostart-enabled=false"
|
||||
case a.Exec == "":
|
||||
a.Because = "the entry has no Exec"
|
||||
default:
|
||||
a.Starts = true
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// i3Starts are the window manager's start-up lines (exec, exec_always) that run a program named
|
||||
// word, in the configuration and its config.d: a second start beside an autostart entry.
|
||||
func (m *Machine) i3Starts(word string) []string {
|
||||
files := []string{m.home(".config", "i3", "config")}
|
||||
more, _ := filepath.Glob(m.home(".config", "i3", "config.d", "*.conf"))
|
||||
files = append(files, more...)
|
||||
var out []string
|
||||
for _, f := range files {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for n, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.TrimSpace(l)
|
||||
if !strings.HasPrefix(t, "exec ") && !strings.HasPrefix(t, "exec_always ") {
|
||||
continue
|
||||
}
|
||||
for _, w := range strings.Fields(t)[1:] {
|
||||
if filepath.Base(strings.Trim(w, `"'`)) == word {
|
||||
out = append(out, fmt.Sprintf("%s:%d: %s", m.tilde(strings.TrimPrefix(f, m.Root)), n+1, t))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// installed asks the package manager for one package's version; "" when it is not installed.
|
||||
func (m *Machine) installed(pkg string) (string, error) {
|
||||
o := m.cmd(0, nil, "pacman", "-Q", pkg)
|
||||
if o.Err != nil {
|
||||
return "", failed(o, "pacman", "-Q", pkg)
|
||||
}
|
||||
if o.Code != 0 {
|
||||
return "", nil
|
||||
}
|
||||
f := strings.Fields(o.Stdout)
|
||||
if len(f) < 2 {
|
||||
return "", fmt.Errorf("pacman -Q %s answered %q", pkg, o.Stdout)
|
||||
}
|
||||
return f[1], nil
|
||||
}
|
||||
|
||||
// Finding is one thing a check found wrong, and what to do about it.
|
||||
type Finding struct {
|
||||
What string `json:"what"`
|
||||
Do string `json:"do,omitempty"`
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
// The fake machine the tests run against, and the tests of desktop.go. The same in every desktop
|
||||
// application's bundle (copies_test.go).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testHome = "/home/operator"
|
||||
|
||||
// fake is a machine with a fake root, a scripted Runner and signals that end fake processes.
|
||||
type fake struct {
|
||||
*Machine
|
||||
t *testing.T
|
||||
mu sync.Mutex
|
||||
calls []string
|
||||
answer func(name string, args []string) Output
|
||||
// onStart is run when systemd-run starts something, to let a fake process appear.
|
||||
onStart func(argv []string)
|
||||
// stubborn pids ignore SIGTERM.
|
||||
stubborn map[int]bool
|
||||
signals []string
|
||||
}
|
||||
|
||||
func newFake(t *testing.T) *fake {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
f := &fake{t: t, stubborn: map[int]bool{}}
|
||||
f.Machine = &Machine{Root: root, Home: testHome, UID: 1000, Timeout: CallTimeout,
|
||||
Sleep: func(time.Duration) {}, Now: func() time.Time { return time.Unix(1_800_000_000, 0) }}
|
||||
f.Run = func(_ context.Context, env []string, name string, args ...string) Output {
|
||||
f.mu.Lock()
|
||||
f.calls = append(f.calls, strings.TrimSpace(name+" "+strings.Join(args, " ")))
|
||||
f.mu.Unlock()
|
||||
if name == "systemd-run" && f.onStart != nil {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
f.onStart(args[i+1:])
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.answer != nil {
|
||||
return f.answer(name, args)
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
f.Kill = func(pid int, sig syscall.Signal) error {
|
||||
f.signals = append(f.signals, strconv.Itoa(pid)+":"+sig.String())
|
||||
if sig == syscall.SIGKILL || !f.stubborn[pid] {
|
||||
return os.RemoveAll(filepath.Join(root, "proc", strconv.Itoa(pid)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
f.write("/proc/stat", "cpu 1 2 3\nbtime 1799990000\n")
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fake) write(path, content string) {
|
||||
f.t.Helper()
|
||||
p := filepath.Join(f.Root, path)
|
||||
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// proc adds a process of uid with a command name, argv, cgroup and environment.
|
||||
func (f *fake) proc(pid, uid int, comm string, argv []string, cgroup string, env ...string) {
|
||||
d := "/proc/" + strconv.Itoa(pid) + "/"
|
||||
f.write(d+"comm", comm+"\n")
|
||||
f.write(d+"status", "Name:\t"+comm+"\nUid:\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\t"+strconv.Itoa(uid)+"\n")
|
||||
f.write(d+"cmdline", strings.Join(argv, "\x00")+"\x00")
|
||||
f.write(d+"cgroup", "0::/user.slice/user-"+strconv.Itoa(uid)+".slice/"+cgroup+"\n")
|
||||
f.write(d+"environ", strings.Join(env, "\x00")+"\x00")
|
||||
// starttime (field 22) is 1000 ticks: 10 s after boot.
|
||||
f.write(d+"stat", strconv.Itoa(pid)+" ("+comm+") S 1 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 1000 0 0\n")
|
||||
}
|
||||
|
||||
func (f *fake) desktopSession() {
|
||||
f.proc(3700, 1000, "i3", []string{"i3"}, "session-c1.scope", "DISPLAY=:1", "XAUTHORITY="+testHome+"/.Xauthority")
|
||||
f.write("/run/user/1000/bus", "")
|
||||
}
|
||||
|
||||
func (f *fake) called(prefix string) bool {
|
||||
for _, c := range f.calls {
|
||||
if strings.HasPrefix(c, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestProcessesAreTheAccountsOwnWithWhereAndWhenTheyStarted(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "worker", []string{"/usr/bin/worker", "--background"}, "session-c1.scope")
|
||||
f.proc(11, 1001, "worker", []string{"/usr/bin/worker"}, "session-c2.scope")
|
||||
f.proc(12, 1000, "other", []string{"other"}, "x.scope")
|
||||
got := f.procs("worker")
|
||||
if len(got) != 1 || got[0].PID != 10 || got[0].Command != "/usr/bin/worker --background" ||
|
||||
got[0].StartedIn != "session-c1.scope" || got[0].Since != time.Unix(1799990010, 0).UTC().Format(time.RFC3339) {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProgramIsToldFromAnotherSharingItsCutName(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.proc(10, 1000, "polychromatic-t", []string{"/usr/bin/python", "/usr/bin/polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(11, 1000, "polychromatic-t", []string{"polychromatic-tray-applet"}, "s.scope")
|
||||
f.proc(12, 1000, "polychromatic-t", []string{"/usr/lib/mesh/polychromatic-tools"}, "s.scope")
|
||||
if got := f.procsOf("polychromatic-t", "polychromatic-tray-applet"); len(got) != 2 || got[0].PID != 10 || got[1].PID != 11 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
if got := f.procsOf("polychromatic-t", ""); len(got) != 3 {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
ended, _ := f.stopProcs(time.Second, f.procsOf("polychromatic-t", "polychromatic-tray-applet"))
|
||||
if len(ended) != 2 || len(f.procs("polychromatic-t")) != 1 {
|
||||
t.Fatalf("ended %v; the tools' own process must stay", ended)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionIsTheWindowManagersAndNoneIsSaidPlainly(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if _, err := f.session(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
f.proc(50, 1000, "xterm", []string{"xterm"}, "s.scope", "DISPLAY=:9")
|
||||
f.desktopSession()
|
||||
f.proc(60, 1001, "i3", []string{"i3"}, "s.scope", "DISPLAY=:5")
|
||||
s, err := f.session()
|
||||
if err != nil || s.Display != ":1" || s.XAuthority != testHome+"/.Xauthority" || s.Bus != "unix:path=/run/user/1000/bus" ||
|
||||
!strings.Contains(s.From, "i3") {
|
||||
t.Fatalf("%+v %v", s, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopAsksThenForcesAndDetachStartsUnderTheServiceManager(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.desktopSession()
|
||||
f.proc(20, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.proc(21, 1000, "app", []string{"app"}, "s.scope")
|
||||
f.stubborn[21] = true
|
||||
ended, killed := f.stop(time.Second, "app")
|
||||
if len(ended) != 1 || ended[0] != 20 || len(killed) != 1 || killed[0] != 21 {
|
||||
t.Fatalf("ended %v killed %v (%v)", ended, killed, f.signals)
|
||||
}
|
||||
s, _ := f.session()
|
||||
if err := f.detach(s, "mesh-app", "/usr/bin/app", "--background"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "systemd-run --user --collect --quiet --unit=mesh-app --setenv=DISPLAY=:1 --setenv=XAUTHORITY=" + testHome +
|
||||
"/.Xauthority -- /usr/bin/app --background"
|
||||
if !f.called("systemctl --user stop mesh-app.service") || !f.called(want) {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAutostartEntryOfTheAccountOverridesTheSystemsAndHiddenStartsNothing(t *testing.T) {
|
||||
f := newFake(t)
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because == "" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write("/etc/xdg/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\n[Desktop Action y]\nExec=other\n")
|
||||
if a := f.autostart("x.desktop"); !a.Starts || a.Exec != "x-applet" || a.From != "/etc/xdg/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
f.write(testHome+"/.config/autostart/x.desktop", "[Desktop Entry]\nExec=x-applet\nHidden=true\n")
|
||||
if a := f.autostart("x.desktop"); a.Starts || a.Because != "Hidden=true" || a.From != "~/.config/autostart/x.desktop" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWindowManagerStartIsFoundInTheConfigurationAndItsDropIns(t *testing.T) {
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/.config/i3/config", "exec --no-startup-id dex --autostart --environment i3\n# exec app\nbindsym $mod+a exec app\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-x.conf", "exec_always --no-startup-id /usr/bin/app --flag\n")
|
||||
got := f.i3Starts("app")
|
||||
if len(got) != 1 || got[0] != "~/.config/i3/config.d/50-x.conf:1: exec_always --no-startup-id /usr/bin/app --flag" {
|
||||
t.Fatalf("%q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACommandThatFailsIsNamed(t *testing.T) {
|
||||
if err := failed(Output{Code: 127, Err: ErrNotInstalled}, "dex"); err == nil || !strings.Contains(err.Error(), "dex is not installed") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{Code: 1, Stderr: "nope"}, "pacman", "-Q", "x"); err == nil || !strings.Contains(err.Error(), "pacman -Q x exited 1: nope") {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := failed(Output{}, "true"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheRealRunnerBoundsTimeAndOutput(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
|
||||
defer cancel()
|
||||
if o := execRun(ctx, nil, "sleep", "5"); o.Err != ErrTimedOut {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
if o := execRun(context.Background(), nil, "no-such-program-here"); o.Err != ErrNotInstalled {
|
||||
t.Fatalf("%+v", o)
|
||||
}
|
||||
o := execRun(context.Background(), nil, "head", "-c", strconv.Itoa(MostOutput+10), "/dev/zero")
|
||||
if !o.Cut || len(o.Stdout) != MostOutput {
|
||||
t.Fatalf("cut %v, %d bytes", o.Cut, len(o.Stdout))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
// The slack module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Slack desktop app
|
||||
// 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.
|
||||
//
|
||||
// Slack's sessions are the operator's: the tools never read its cookies, local storage or the key in
|
||||
// Local State, and no answer carries a token, a cookie, a message, or the name of a person, channel
|
||||
// or workspace. Accounts and workspaces are counted, never named.
|
||||
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: "slack_status",
|
||||
Description: "Slack: whether it runs (the main process's pid, since, and the scope it runs in; how " +
|
||||
"many helper processes), the installed version and whether it is outside the official " +
|
||||
"repositories, where its output goes, whether its icon sits in a tray, who shows its " +
|
||||
"notifications, its launch and tray switches and notification method, how many accounts and " +
|
||||
"workspaces it is signed in to (counted, never named), and what starts it at login. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Status() },
|
||||
},
|
||||
{
|
||||
Name: "slack_log",
|
||||
Description: "The last lines of Slack's own log: source browser (the main process, the default) or " +
|
||||
"webapp (the web app's console). With problems, only error and warn entries. Tokens, cookies, " +
|
||||
"message and notification text, and the names of people, channels and workspaces are " +
|
||||
"replaced by <hidden>. (r)",
|
||||
Input: map[string]any{
|
||||
"lines": map[string]any{"type": "integer", "description": "how many lines (default 100, at most 2000)"},
|
||||
"source": map[string]any{"type": "string", "enum": []string{"browser", "webapp"}, "description": "browser (default) or webapp"},
|
||||
"problems": map[string]any{"type": "boolean", "description": "only error and warn entries (default false)"},
|
||||
},
|
||||
Run: func(args map[string]any) (any, error) {
|
||||
n, err := whole(args, "lines", 100, 1, 2000)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source, err := text(args, "source", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if source == "" {
|
||||
source = "browser"
|
||||
}
|
||||
problems, err := flag(args, "problems", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return machine.Log(source, n, problems)
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "slack_restart",
|
||||
Description: "End Slack (asked first, then forced after 8 s) and start it again to the tray in the " +
|
||||
"operator's desktop session, under the account's service manager, its output in the journal. " +
|
||||
"Answers the pids ended and the new main process. Needs someone logged in to the desktop. (a)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Restart() },
|
||||
},
|
||||
{
|
||||
Name: "slack_check",
|
||||
Description: "Check what the module promises: Slack is installed; it has exactly one start (the " +
|
||||
"module's line in i3's configuration; no XDG autostart entry, no other exec); the window rules " +
|
||||
"are the module's, not repeated in a file of the operator's; one Slack runs in the session with " +
|
||||
"its output read (a pipe nobody reads makes its writes fail with EPIPE); its icon is in a tray; " +
|
||||
"a notifier owns the session bus's notification name. Answers ok and each finding with what to do. (r)",
|
||||
Run: func(map[string]any) (any, error) { return machine.Check() },
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// slack's shape (novox/hq ADR 0208, ADR 0210, ADR 0212, ADR 0182): no package (Slack comes from the
|
||||
// AUR, and the host installs from the official repositories only), no seat, no file; the X display on
|
||||
// its own machine; one contribution to node-display-session that is both Slack's one start and its
|
||||
// window rules; and the Go bundle serving exactly the listed slack_ 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 TestItInstallsNothingAndOwnsNoFile(t *testing.T) {
|
||||
m, raw := readManifest(t)
|
||||
if m.Module != "slack" || !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
|
||||
t.Fatalf("%+v", m)
|
||||
}
|
||||
// slack-desktop is the AUR's: a package resource would ask the host for what it cannot install.
|
||||
if m.Resources != nil || m.Capabilities != nil {
|
||||
t.Fatalf("no package, no file: %v %v", m.Resources, m.Capabilities)
|
||||
}
|
||||
if m.Claims != nil || m.Seats != nil || m.Environment != nil || m.Shell != nil {
|
||||
t.Fatal("it holds no seat, sets no environment and adds no session code")
|
||||
}
|
||||
// Slack's own setting writes ~/.config/autostart/slack.desktop (a link): never the module's.
|
||||
for _, never := range []string{".config/autostart", ".config/Slack", "root-state", "Cookies"} {
|
||||
if strings.Contains(raw, never) {
|
||||
t.Errorf("module.json names %q", never)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheOneContributionIsTheStartAndTheWindowRules(t *testing.T) {
|
||||
m, _ := readManifest(t)
|
||||
if len(m.Contributions) != 1 {
|
||||
t.Fatalf("%+v", m.Contributions)
|
||||
}
|
||||
c := m.Contributions[0]
|
||||
if c.Seat != "node-display-session" || c.Kind != "config" || !strings.HasSuffix(c.Content, "\n") {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
var execs, rules []string
|
||||
for _, l := range strings.Split(c.Content, "\n") {
|
||||
l = strings.TrimSpace(l)
|
||||
switch {
|
||||
case strings.HasPrefix(l, "exec"):
|
||||
execs = append(execs, l)
|
||||
case strings.HasPrefix(l, "for_window"):
|
||||
rules = append(rules, l)
|
||||
case l == "" || strings.HasPrefix(l, "#"):
|
||||
default:
|
||||
t.Errorf("a line that is neither the start nor a rule: %q", l)
|
||||
}
|
||||
}
|
||||
want := "exec --no-startup-id " + slackBin + " " + strings.Join(startArgs, " ")
|
||||
if len(execs) != 1 || execs[0] != want {
|
||||
t.Fatalf("one start, the package entry's arguments, as slack_restart starts it: %q", execs)
|
||||
}
|
||||
// The operator's rules of 2026-08 (50-slack.conf), verified then against Slack's windows.
|
||||
wantRules := []string{
|
||||
`for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3`,
|
||||
`for_window [class="(?i)^slack$" window_role="browser-window"] floating disable`,
|
||||
`for_window [class="(?i)^slack$" window_role="browser-window"] border pixel 2`,
|
||||
}
|
||||
if !reflect.DeepEqual(rules, wantRules) {
|
||||
t.Fatalf("%q", rules)
|
||||
}
|
||||
for _, r := range rules {
|
||||
if !strings.HasPrefix(r, ruleMark) {
|
||||
t.Errorf("slack_check recognises the module's rules by %q: %s", ruleMark, r)
|
||||
}
|
||||
}
|
||||
// The placed lines start Slack once: the fake i3 configuration below holds exactly this content.
|
||||
f := newFake(t)
|
||||
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+c.Content)
|
||||
if s := f.starts(); len(s) != 1 || !s[0].Mine {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
if n, _ := f.windowRules(); n != 3 {
|
||||
t.Fatalf("%d rules", n)
|
||||
}
|
||||
}
|
||||
|
||||
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, "slack_") || strings.TrimSpace(tool.Description) == "" {
|
||||
t.Errorf("%s: prefixed slack_ and described", tool.Name)
|
||||
}
|
||||
}
|
||||
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/slack-tools" || b["binary"] != "slack-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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,678 @@
|
||||
package main
|
||||
|
||||
// Slack as the tools see it. Slack is one Electron process tree: the main process (comm slack, no
|
||||
// --type=) is both the window and the tray icon, and its helpers (--type=zygote, gpu-process,
|
||||
// renderer, utility) are its children. The tools read only:
|
||||
// - the account's processes, and of Slack's main process where its output goes (fd 1 and 2);
|
||||
// - Slack's settings file, ~/.config/Slack/storage/root-state.json, of which only switches are
|
||||
// answered (launch on login, start hidden, run from the tray, notification method, hardware
|
||||
// acceleration), and local-settings.json for the Electron version;
|
||||
// - Slack's own log, ~/.config/Slack/logs/default/browser*.log, from which the signed-in accounts and
|
||||
// workspaces are counted (counted only: no name, no id) and which slack_log answers masked;
|
||||
// - the X window tree (xwininfo), for the tray icon; the session bus, for who shows notifications.
|
||||
//
|
||||
// Never read: Cookies, Local Storage, IndexedDB, the os_crypt key in Local State. Those are the
|
||||
// sessions' credentials, and no tool needs them.
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Where Slack keeps things, under the operator's home, and how it is started.
|
||||
const (
|
||||
slackComm = "slack"
|
||||
slackBin = "/usr/bin/slack"
|
||||
packageFor = "slack-desktop"
|
||||
restartAs = "mesh-slack"
|
||||
entryName = "slack.desktop"
|
||||
stateFile = ".config/Slack/storage/root-state.json"
|
||||
localFile = ".config/Slack/local-settings.json"
|
||||
logDir = ".config/Slack/logs/default"
|
||||
i3Main = ".config/i3/config"
|
||||
i3DropIns = ".config/i3/config.d"
|
||||
notifyName = "org.freedesktop.Notifications"
|
||||
)
|
||||
|
||||
// startArgs are the package's own desktop entry's arguments: GTK 3, and -s, started to the tray.
|
||||
var startArgs = []string{"--gtk-version=3", "-s"}
|
||||
|
||||
// ruleMark is how the module's window rules are recognised wherever they are found.
|
||||
const ruleMark = `for_window [class="(?i)^slack$"`
|
||||
|
||||
// readLink is how a process's file descriptors are read; tests replace it, so no test makes a link.
|
||||
var readLink = os.Readlink
|
||||
|
||||
// isLink says whether a path is a symbolic link itself; tests replace it for the same reason.
|
||||
var isLink = func(p string) bool {
|
||||
fi, err := os.Lstat(p)
|
||||
return err == nil && fi.Mode()&os.ModeSymlink != 0
|
||||
}
|
||||
|
||||
// mainProcs are Slack's main processes: comm slack without --type=.
|
||||
func (m *Machine) mainProcs() []Proc {
|
||||
var out []Proc
|
||||
for _, p := range m.procs(slackComm) {
|
||||
if !strings.Contains(p.Command, "--type=") {
|
||||
out = append(out, p)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Stream is where a process's standard output or error goes.
|
||||
type Stream struct {
|
||||
FD int `json:"fd"`
|
||||
Goes string `json:"goes"`
|
||||
// Dead is a pipe no process of the account reads: a write there fails with EPIPE.
|
||||
Dead bool `json:"dead,omitempty"`
|
||||
}
|
||||
|
||||
// streams says where fd 1 and 2 of a process go.
|
||||
func (m *Machine) streams(pid int) []Stream {
|
||||
var out []Stream
|
||||
for _, fd := range []int{1, 2} {
|
||||
target, err := readLink(m.path(fmt.Sprintf("/proc/%d/fd/%d", pid, fd)))
|
||||
s := Stream{FD: fd}
|
||||
switch {
|
||||
case err != nil:
|
||||
s.Goes = "unreadable"
|
||||
case target == "/dev/null":
|
||||
s.Goes = "discarded (/dev/null)"
|
||||
case strings.HasPrefix(target, "socket:"):
|
||||
s.Goes = "a socket (the journal, when the session's output is sent there)"
|
||||
case strings.HasPrefix(target, "pipe:"):
|
||||
if r := m.pipeReaders(target, pid); len(r) > 0 {
|
||||
s.Goes = "a pipe read by " + strings.Join(r, ", ")
|
||||
} else {
|
||||
s.Goes, s.Dead = "a pipe nobody reads", true
|
||||
}
|
||||
default:
|
||||
s.Goes = m.tilde(target)
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pipeReaders are the account's processes holding the pipe open for reading (O_RDONLY in fdinfo).
|
||||
func (m *Machine) pipeReaders(pipe string, except int) []string {
|
||||
entries, _ := os.ReadDir(m.path("/proc"))
|
||||
seen := map[string]bool{}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
pid, err := strconv.Atoi(e.Name())
|
||||
if err != nil || pid == except {
|
||||
continue
|
||||
}
|
||||
dir := m.path(filepath.Join("/proc", e.Name()))
|
||||
if m.uidOf(dir) != m.UID {
|
||||
continue
|
||||
}
|
||||
fds, _ := os.ReadDir(filepath.Join(dir, "fd"))
|
||||
for _, fd := range fds {
|
||||
if t, err := readLink(filepath.Join(dir, "fd", fd.Name())); err != nil || t != pipe {
|
||||
continue
|
||||
}
|
||||
if readOnly(readTrimmed(filepath.Join(dir, "fdinfo", fd.Name()))) {
|
||||
name := fmt.Sprintf("%s (pid %d)", readTrimmed(filepath.Join(dir, "comm")), pid)
|
||||
if !seen[name] {
|
||||
seen[name] = true
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// readOnly reads an fdinfo's flags (octal): the access mode is its low two bits, 0 for reading.
|
||||
func readOnly(fdinfo string) bool {
|
||||
for _, l := range strings.Split(fdinfo, "\n") {
|
||||
if f := strings.Fields(l); len(f) == 2 && f[0] == "flags:" {
|
||||
n, err := strconv.ParseInt(f[1], 8, 64)
|
||||
return err == nil && n&3 == 0
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Settings are the switches of Slack's settings the tools answer. Every other key stays unread.
|
||||
type Settings struct {
|
||||
Found bool `json:"found"`
|
||||
LaunchOnLogin *bool `json:"launch_on_login,omitempty"`
|
||||
HideOnStartup *bool `json:"hide_on_startup,omitempty"`
|
||||
RunFromTray *bool `json:"run_from_tray,omitempty"`
|
||||
NotificationMethod string `json:"notification_method,omitempty"`
|
||||
HardwareAccel *bool `json:"hardware_acceleration,omitempty"`
|
||||
Electron string `json:"electron,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Machine) settings() Settings {
|
||||
var s Settings
|
||||
raw, err := readBounded(m.home(stateFile))
|
||||
if err == nil {
|
||||
var doc struct {
|
||||
Settings struct {
|
||||
LaunchOnStartup *bool `json:"launchOnStartup"`
|
||||
HideOnStartup *bool `json:"hideOnStartup"`
|
||||
RunFromTray *bool `json:"runFromTray"`
|
||||
NotificationMethod *string `json:"notificationMethod"`
|
||||
UseHwAcceleration *bool `json:"useHwAcceleration"`
|
||||
} `json:"settings"`
|
||||
}
|
||||
if json.Unmarshal(raw, &doc) == nil {
|
||||
s.Found = true
|
||||
s.LaunchOnLogin, s.HideOnStartup, s.RunFromTray = doc.Settings.LaunchOnStartup, doc.Settings.HideOnStartup, doc.Settings.RunFromTray
|
||||
s.HardwareAccel = doc.Settings.UseHwAcceleration
|
||||
s.NotificationMethod = "Slack's default"
|
||||
if doc.Settings.NotificationMethod != nil && *doc.Settings.NotificationMethod != "" {
|
||||
s.NotificationMethod = *doc.Settings.NotificationMethod
|
||||
}
|
||||
}
|
||||
}
|
||||
if raw, err := readBounded(m.home(localFile)); err == nil {
|
||||
var doc struct {
|
||||
Electron string `json:"lastElectronVersionLaunched"`
|
||||
}
|
||||
if json.Unmarshal(raw, &doc) == nil {
|
||||
s.Electron = doc.Electron
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// Workspaces is how many accounts Slack is signed in to, and how many workspaces they open: counted
|
||||
// from the last report Slack logged of each account, since its last start. Never a name or an id.
|
||||
type Workspaces struct {
|
||||
Accounts int `json:"accounts"`
|
||||
Workspaces int `json:"workspaces"`
|
||||
AsOf string `json:"as_of,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
var logStamp = regexp.MustCompile(`^\[(\d\d/\d\d/\d\d, \d\d:\d\d:\d\d:\d+)\] (\w+): `)
|
||||
|
||||
// browserLogs are Slack's main-process logs, oldest first (browser2.log, browser1.log, browser.log).
|
||||
func (m *Machine) browserLogs() []string {
|
||||
files, _ := filepath.Glob(m.home(logDir, "browser*.log"))
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
a, _ := os.Stat(files[i])
|
||||
b, _ := os.Stat(files[j])
|
||||
if a == nil || b == nil {
|
||||
return files[i] < files[j]
|
||||
}
|
||||
return a.ModTime().Before(b.ModTime())
|
||||
})
|
||||
if len(files) > 3 {
|
||||
files = files[len(files)-3:]
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
func (m *Machine) workspaces() Workspaces {
|
||||
var lines []string
|
||||
for _, f := range m.browserLogs() {
|
||||
l, err := tailLines(f, 1<<20)
|
||||
if err == nil {
|
||||
lines = append(lines, l...)
|
||||
}
|
||||
}
|
||||
start := 0
|
||||
for i := len(lines) - 1; i >= 0; i-- {
|
||||
if strings.Contains(lines[i], "] info: Store: INITIALIZE") {
|
||||
start = i
|
||||
break
|
||||
}
|
||||
}
|
||||
accounts := map[string][]string{}
|
||||
var asOf string
|
||||
for i := start; i < len(lines); i++ {
|
||||
if !strings.Contains(lines[i], "Store: STORE_USER_WORKSPACES") {
|
||||
continue
|
||||
}
|
||||
var block strings.Builder
|
||||
j := i + 1
|
||||
for ; j < len(lines) && !logStamp.MatchString(lines[j]); j++ {
|
||||
block.WriteString(lines[j])
|
||||
}
|
||||
var report struct {
|
||||
User string `json:"userTeamId"`
|
||||
Workspaces []string `json:"workspaceIds"`
|
||||
}
|
||||
if json.Unmarshal([]byte(block.String()), &report) == nil && report.User != "" {
|
||||
accounts[report.User] = report.Workspaces
|
||||
if mm := logStamp.FindStringSubmatch(lines[i]); mm != nil {
|
||||
asOf = mm[1]
|
||||
}
|
||||
}
|
||||
i = j - 1
|
||||
}
|
||||
w := Workspaces{Accounts: len(accounts), AsOf: asOf}
|
||||
all := map[string]bool{}
|
||||
for _, ws := range accounts {
|
||||
for _, id := range ws {
|
||||
all[id] = true
|
||||
}
|
||||
}
|
||||
w.Workspaces = len(all)
|
||||
if len(accounts) == 0 {
|
||||
w.Note = "Slack has logged no account since its last start: signed out, or not started yet"
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Tray is whether Slack's icon sits in a tray: a window of class "Slack" reparented into another
|
||||
// program's window (i3bar's tray), found in the X window tree.
|
||||
type Tray struct {
|
||||
Present bool `json:"present"`
|
||||
In string `json:"in,omitempty"`
|
||||
Unknown string `json:"unknown,omitempty"`
|
||||
}
|
||||
|
||||
var treeLine = regexp.MustCompile(`^(\s*)0x[0-9a-f]+ (?:"[^"]*"|\(has no name\)): \("([^"]*)" "([^"]*)"\)`)
|
||||
|
||||
// trayIn reads `xwininfo -root -tree`: each window is indented under its parent.
|
||||
func trayIn(tree string) (bool, string) {
|
||||
type frame struct {
|
||||
indent int
|
||||
class string
|
||||
}
|
||||
var stack []frame
|
||||
for _, l := range strings.Split(tree, "\n") {
|
||||
trimmed := strings.TrimLeft(l, " ")
|
||||
if !strings.HasPrefix(trimmed, "0x") {
|
||||
continue
|
||||
}
|
||||
indent := len(l) - len(trimmed)
|
||||
for len(stack) > 0 && stack[len(stack)-1].indent >= indent {
|
||||
stack = stack[:len(stack)-1]
|
||||
}
|
||||
mm := treeLine.FindStringSubmatch(l)
|
||||
class := ""
|
||||
if mm != nil {
|
||||
class = mm[3]
|
||||
if mm[2] == "slack" && class == "Slack" {
|
||||
for k := len(stack) - 1; k >= 0; k-- {
|
||||
if c := stack[k].class; c != "" && !strings.EqualFold(c, "slack") {
|
||||
return true, c
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stack = append(stack, frame{indent, class})
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
|
||||
func (m *Machine) tray(s Session) Tray {
|
||||
o := m.cmd(5*time.Second, s.Env(), "xwininfo", "-root", "-tree")
|
||||
if err := failed(o, "xwininfo", "-root", "-tree"); err != nil {
|
||||
if o.Err == ErrNotInstalled {
|
||||
return Tray{Unknown: "xwininfo (package xorg-xwininfo) is not installed, so the window tree cannot be read"}
|
||||
}
|
||||
return Tray{Unknown: err.Error()}
|
||||
}
|
||||
ok, in := trayIn(o.Stdout)
|
||||
return Tray{Present: ok, In: in}
|
||||
}
|
||||
|
||||
// Notifier is who shows Slack's notifications: the owner of the session bus's notification name.
|
||||
type Notifier struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
PID int `json:"pid,omitempty"`
|
||||
// Activatable is a notifier that is not running but that the bus starts on the first notification.
|
||||
Activatable bool `json:"activatable,omitempty"`
|
||||
Unknown string `json:"unknown,omitempty"`
|
||||
}
|
||||
|
||||
func (m *Machine) notifier() Notifier {
|
||||
o := m.cmd(5*time.Second, m.bus(), "busctl", "--user", "list", "--no-legend", "--no-pager")
|
||||
if err := failed(o, "busctl", "--user", "list"); err != nil {
|
||||
return Notifier{Unknown: err.Error()}
|
||||
}
|
||||
for _, l := range strings.Split(o.Stdout, "\n") {
|
||||
f := strings.Fields(l)
|
||||
if len(f) < 3 || f[0] != notifyName {
|
||||
continue
|
||||
}
|
||||
if pid, err := strconv.Atoi(f[1]); err == nil {
|
||||
return Notifier{Owner: f[2], PID: pid}
|
||||
}
|
||||
return Notifier{Activatable: true}
|
||||
}
|
||||
return Notifier{}
|
||||
}
|
||||
|
||||
// Status is what slack_status answers.
|
||||
type Status struct {
|
||||
Installed string `json:"installed,omitempty"`
|
||||
Foreign bool `json:"outside_the_official_repositories,omitempty"`
|
||||
Running []Proc `json:"running"`
|
||||
Helpers int `json:"helper_processes"`
|
||||
Output []Stream `json:"output,omitempty"`
|
||||
Tray *Tray `json:"tray,omitempty"`
|
||||
Notifier Notifier `json:"notifications_to"`
|
||||
Settings Settings `json:"settings"`
|
||||
Workspaces Workspaces `json:"signed_in"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// foreign says whether pacman counts the package as foreign: installed, but in no sync repository.
|
||||
func (m *Machine) foreign(pkg string) bool {
|
||||
o := m.cmd(0, nil, "pacman", "-Qmq", pkg)
|
||||
return o.Err == nil && o.Code == 0 && strings.TrimSpace(o.Stdout) == pkg
|
||||
}
|
||||
|
||||
func (m *Machine) Status() (Status, error) {
|
||||
s := Status{Running: m.mainProcs(), Settings: m.settings(), Workspaces: m.workspaces(), Starts: []string{}}
|
||||
if s.Running == nil {
|
||||
s.Running = []Proc{}
|
||||
}
|
||||
s.Helpers = len(m.procs(slackComm)) - len(s.Running)
|
||||
if v, err := m.installed(packageFor); err == nil && v != "" {
|
||||
s.Installed, s.Foreign = v, m.foreign(packageFor)
|
||||
}
|
||||
if len(s.Running) > 0 {
|
||||
s.Output = m.streams(s.Running[0].PID)
|
||||
if sess, err := m.session(); err == nil {
|
||||
t := m.tray(sess)
|
||||
s.Tray = &t
|
||||
}
|
||||
}
|
||||
s.Notifier = m.notifier()
|
||||
for _, st := range m.starts() {
|
||||
s.Starts = append(s.Starts, st.What)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Start is one thing that starts Slack at login; Mine is the module's own.
|
||||
type Start struct {
|
||||
What string
|
||||
Mine bool
|
||||
Do string
|
||||
}
|
||||
|
||||
// starts are every start of Slack at login the tools can see: window-manager exec lines (the module's
|
||||
// in i3's main file, any other in the operator's drop-ins) and XDG autostart entries.
|
||||
func (m *Machine) starts() []Start {
|
||||
var out []Start
|
||||
main := m.tilde(filepath.Join(m.Home, i3Main)) + ":"
|
||||
for _, l := range m.i3Starts(slackComm) {
|
||||
if strings.HasPrefix(l, main) {
|
||||
out = append(out, Start{What: "window manager (this module's line): " + l, Mine: true})
|
||||
} else {
|
||||
out = append(out, Start{What: "window manager: " + l, Do: "remove the line: the module's line in i3's configuration is Slack's one start"})
|
||||
}
|
||||
}
|
||||
if a := m.autostart(entryName); a.Starts {
|
||||
st := Start{What: "XDG autostart: " + a.From + " (" + a.Exec + ")"}
|
||||
user := m.home(".config", "autostart", entryName)
|
||||
if isLink(user) {
|
||||
st.Do = "untick 'Launch app on login' in Slack's preferences: Slack made this link for that setting and removes it when unticked"
|
||||
} else if strings.HasPrefix(a.From, "~") {
|
||||
st.Do = "delete " + a.From + " (the predecessor's): Slack then reads 'Launch app on login' as off, and the module's line is the one start"
|
||||
} else {
|
||||
st.Do = "hide it with an entry of the account (Hidden=true): the module's line is Slack's one start"
|
||||
}
|
||||
out = append(out, st)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// LogAnswer is what slack_log answers.
|
||||
type LogAnswer struct {
|
||||
Source string `json:"source"`
|
||||
Files []string `json:"files"`
|
||||
Lines []string `json:"lines"`
|
||||
Cut bool `json:"cut,omitempty"`
|
||||
Note string `json:"note,omitempty"`
|
||||
}
|
||||
|
||||
const mostAnswer = 256 << 10
|
||||
|
||||
// The masks. A token or a cookie is hidden wherever it appears; a notification's or a message's text,
|
||||
// and the names of people, channels and workspaces, are hidden by their key.
|
||||
var (
|
||||
slackToken = regexp.MustCompile(`xox[a-z]-[A-Za-z0-9-]+|xapp-[A-Za-z0-9-]+`)
|
||||
credential = regexp.MustCompile(`(?i)\b(authorization|bearer|token|password|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:bearer\s+)?[^\s"',;&]+`)
|
||||
cookieD = regexp.MustCompile(`\bd=[A-Za-z0-9%/+=._-]{20,}`)
|
||||
privateKey = regexp.MustCompile(`"(title|subtitle|content|body|text|authorName|userName|channelName|workspaceName|teamName|name|email|realName|displayName)": "(?:[^"\\]|\\.)*"`)
|
||||
)
|
||||
|
||||
func mask(s string) string {
|
||||
s = slackToken.ReplaceAllString(s, "<hidden>")
|
||||
s = cookieD.ReplaceAllString(s, "d=<hidden>")
|
||||
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
|
||||
return privateKey.ReplaceAllString(s, `"${1}": "<hidden>"`)
|
||||
}
|
||||
|
||||
// Log answers the last n lines of Slack's main-process log (source browser) or of its web app's
|
||||
// console log (source webapp), masked; problems keeps the error and warning entries.
|
||||
func (m *Machine) Log(source string, n int, problems bool) (LogAnswer, error) {
|
||||
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
|
||||
var files []string
|
||||
switch source {
|
||||
case "browser":
|
||||
files = m.browserLogs()
|
||||
case "webapp":
|
||||
files, _ = filepath.Glob(m.home(logDir, "webapp-console*.log"))
|
||||
sort.Slice(files, func(i, j int) bool {
|
||||
a, _ := os.Stat(files[i])
|
||||
b, _ := os.Stat(files[j])
|
||||
return a != nil && b != nil && a.ModTime().Before(b.ModTime())
|
||||
})
|
||||
default:
|
||||
return a, fmt.Errorf("source is browser or webapp, not %q", source)
|
||||
}
|
||||
if len(files) == 0 {
|
||||
a.Note = "Slack keeps no such log in ~/" + logDir
|
||||
return a, nil
|
||||
}
|
||||
var got []string
|
||||
for i := len(files) - 1; i >= 0 && len(got) < n && len(a.Files) < 3; i-- {
|
||||
lines, err := tailLines(files[i], 1<<20)
|
||||
if err != nil {
|
||||
return a, err
|
||||
}
|
||||
if problems {
|
||||
var keep []string
|
||||
for _, l := range lines {
|
||||
if mm := logStamp.FindStringSubmatch(l); mm != nil && (mm[2] == "error" || mm[2] == "warn") {
|
||||
keep = append(keep, l)
|
||||
}
|
||||
}
|
||||
lines = keep
|
||||
}
|
||||
if len(lines) > n-len(got) {
|
||||
lines = lines[len(lines)-(n-len(got)):]
|
||||
}
|
||||
got = append(lines, got...)
|
||||
a.Files = append(a.Files, m.tilde(strings.TrimPrefix(files[i], m.Root)))
|
||||
}
|
||||
size := 0
|
||||
for i := len(got) - 1; i >= 0; i-- {
|
||||
l := mask(got[i])
|
||||
if size+len(l) > mostAnswer {
|
||||
a.Cut = true
|
||||
got = got[i+1:]
|
||||
break
|
||||
}
|
||||
size += len(l) + 1
|
||||
got[i] = l
|
||||
}
|
||||
if got == nil {
|
||||
got = []string{}
|
||||
}
|
||||
a.Lines = got
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// tailLines answers the last n lines of a file, read from its end to at most MostRead.
|
||||
func tailLines(path string, n int) ([]string, error) {
|
||||
h, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer h.Close()
|
||||
if st, err := h.Stat(); err == nil && st.Size() > MostRead {
|
||||
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
var all []string
|
||||
s := bufio.NewScanner(io.LimitReader(h, MostRead))
|
||||
s.Buffer(make([]byte, 64<<10), 4<<20)
|
||||
for s.Scan() {
|
||||
all = append(all, string(bytes.TrimRight(s.Bytes(), "\r")))
|
||||
}
|
||||
if len(all) > n {
|
||||
all = all[len(all)-n:]
|
||||
}
|
||||
return all, s.Err()
|
||||
}
|
||||
|
||||
// RestartAnswer is what slack_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 Slack (the main process and its helpers) and starts it again to the tray, in the
|
||||
// operator's session, under the account's service manager: its output then goes to the journal.
|
||||
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(8*time.Second, slackComm)
|
||||
if err := m.detach(s, restartAs, append([]string{slackBin}, startArgs...)...); err != nil {
|
||||
return a, err
|
||||
}
|
||||
m.waitFor(slackComm, 6*time.Second)
|
||||
a.Running = m.mainProcs()
|
||||
if a.Running == nil {
|
||||
a.Running = []Proc{}
|
||||
}
|
||||
if len(a.Running) == 0 {
|
||||
return a, fmt.Errorf("Slack was started as %s but no main process appeared within 6 s: "+
|
||||
"see `journalctl --user -u %s`", a.Unit, a.Unit)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// CheckAnswer is what slack_check answers.
|
||||
type CheckAnswer struct {
|
||||
OK bool `json:"ok"`
|
||||
Findings []Finding `json:"findings"`
|
||||
Starts []string `json:"starts"`
|
||||
}
|
||||
|
||||
// Check verifies what the module promises: Slack installed, one start (the module's line), the window
|
||||
// rules once, one Slack running with its output read, its icon in the tray, and a notifier.
|
||||
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("Slack ("+packageFor+") is not installed", "install it from the AUR: the module does not install it, because the host installs packages from the official repositories only")
|
||||
}
|
||||
mine := 0
|
||||
for _, st := range m.starts() {
|
||||
a.Starts = append(a.Starts, st.What)
|
||||
if st.Mine {
|
||||
mine++
|
||||
} else {
|
||||
add("a second start: "+st.What, st.Do)
|
||||
}
|
||||
}
|
||||
if mine == 0 {
|
||||
add("i3's configuration has not got the module's start of Slack", "assign the i3 module, which places this module's lines, and push the node")
|
||||
} else if mine > 1 {
|
||||
add(fmt.Sprintf("i3's configuration starts Slack %d times", mine), "push the node: the module's lines are placed once")
|
||||
}
|
||||
rules, operators := m.windowRules()
|
||||
if rules == 0 {
|
||||
add("i3's configuration has not got the module's window rules for Slack", "assign the i3 module and push the node")
|
||||
}
|
||||
for _, f := range operators {
|
||||
add("the operator's "+f+" repeats Slack's window rules, which are this module's now", "delete "+f+": i3 reads it after the module's lines")
|
||||
}
|
||||
running := m.mainProcs()
|
||||
sess, serr := m.session()
|
||||
if serr == nil {
|
||||
switch {
|
||||
case len(running) == 0:
|
||||
add("Slack does not run in the desktop session", "slack_restart")
|
||||
case len(running) > 1:
|
||||
add(fmt.Sprintf("%d Slack main processes run", len(running)), "slack_restart ends them all and starts one")
|
||||
}
|
||||
}
|
||||
if len(running) > 0 {
|
||||
for _, st := range m.streams(running[0].PID) {
|
||||
if st.Dead {
|
||||
add(fmt.Sprintf("Slack's fd %d is a pipe nobody reads (a session started before its output went to the journal): "+
|
||||
"its logger silences the EPIPE it gets there, any other write it makes fails", st.FD),
|
||||
"slack_restart: its output then goes to the journal. Every later login sends the session's output there (the i3 module's login entry)")
|
||||
}
|
||||
}
|
||||
if serr == nil {
|
||||
if t := m.tray(sess); !t.Present && t.Unknown == "" {
|
||||
add("Slack's icon is in no tray: closing its window leaves it unreachable", "give the bar a tray (i3bar's tray_output), then slack_restart")
|
||||
}
|
||||
}
|
||||
}
|
||||
if n := m.notifier(); n.Unknown == "" && n.Owner == "" && !n.Activatable {
|
||||
add("nobody shows notifications on the session bus ("+notifyName+"): Slack's go nowhere", "assign dunst, the node's notifier")
|
||||
}
|
||||
a.OK = len(a.Findings) == 0
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// windowRules counts the module's rule lines in i3's main file, and names the operator's drop-ins that
|
||||
// carry Slack's rules too.
|
||||
func (m *Machine) windowRules() (int, []string) {
|
||||
n := 0
|
||||
if raw, err := readBounded(m.home(i3Main)); err == nil {
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(l), ruleMark) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
var files []string
|
||||
more, _ := filepath.Glob(m.home(i3DropIns, "*.conf"))
|
||||
for _, f := range more {
|
||||
raw, err := readBounded(f)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, l := range strings.Split(string(raw), "\n") {
|
||||
t := strings.ToLower(strings.TrimSpace(l))
|
||||
if strings.HasPrefix(t, "for_window") && strings.Contains(t, "slack") {
|
||||
files = append(files, m.tilde(strings.TrimPrefix(f, m.Root)))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return n, files
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Slack's settings as Slack writes them, with keys no answer may carry.
|
||||
const rootState = `{"appTeams":{"selectedTeamId":null,"teamsByIndex":[]},
|
||||
"settings":{"launchOnStartup":true,"hideOnStartup":true,"runFromTray":true,"notificationMethod":null,
|
||||
"useHwAcceleration":true,"PrefSSBFileDownloadPath":"/home/operator/Downloads","signInMethod":"secret-sso"}}`
|
||||
|
||||
// A main-process log across a restart: the accounts before it are not counted.
|
||||
const browserLog = `[10/03/26, 09:00:00:001] info: Store: INITIALIZE
|
||||
[10/03/26, 09:00:05:001] info: Store: STORE_USER_WORKSPACES
|
||||
{
|
||||
"userTeamId": "TOLD0000001",
|
||||
"workspaceIds": [
|
||||
"TOLD0000001",
|
||||
"TOLD0000002"
|
||||
]
|
||||
}
|
||||
[10/04/26, 16:26:20:100] info: Store: INITIALIZE
|
||||
[10/04/26, 16:26:30:100] info: Store: STORE_USER_WORKSPACES
|
||||
{
|
||||
"userTeamId": "TAAAA000001",
|
||||
"workspaceIds": [
|
||||
"TAAAA000001"
|
||||
]
|
||||
}
|
||||
[10/04/26, 16:26:31:100] warn: Request failed with token xoxc-1234-5678-abcdef and cookie d=xoxd-AbCdEfGhIjKlMnOpQrStUv%2Fwx
|
||||
[10/04/26, 16:27:00:100] info: Store: NEW_NOTIFICATION
|
||||
{
|
||||
"title": "A private message",
|
||||
"workspaceName": "Secret Workspace",
|
||||
"teamId": "TAAAA000001"
|
||||
}
|
||||
[10/04/26, 23:41:06:340] info: Store: STORE_USER_WORKSPACES
|
||||
{
|
||||
"userTeamId": "TBBBB000001",
|
||||
"workspaceIds": [
|
||||
"TBBBB000001",
|
||||
"TAAAA000001"
|
||||
]
|
||||
}
|
||||
[10/04/26, 23:41:07:000] error: Authorization: Bearer abc.def failed
|
||||
`
|
||||
|
||||
// xwininfo -root -tree as the laptop answered it: the chat window under i3's frame, the tray icon in
|
||||
// i3bar, two helper windows at the top.
|
||||
const tree = `xwininfo: Window id: 0x3d7 (the root window) (has no name)
|
||||
|
||||
Root window id: 0x3d7 (the root window) (has no name)
|
||||
Parent window id: 0x0 (none)
|
||||
12 children:
|
||||
0x28000d1 "slack": ("slack" "Slack") 200x200+0+0 +0+0
|
||||
0x1400003 (has no name): ("i3-frame" "i3-frame") 1440x1774+0+26 +0+26
|
||||
1 child:
|
||||
0x2600004 "Slack | general": ("slack" "slack") 1436x1744+2+2 +2+28
|
||||
0x1002168 (has no name): () 1x1+0+0 +0+0
|
||||
0x1002169 "i3bar for output eDP-1": ("bar-1" "i3bar") 2880x26+0+0 +0+0
|
||||
1 child:
|
||||
0x2800029 "slack": ("slack" "Slack") 22x22+2832+2 +2832+2
|
||||
0x2800001 "slack": ("slack" "Slack") 10x10+10+10 +10+10
|
||||
`
|
||||
|
||||
// newSlack is a machine with Slack installed from the AUR, running from a session whose output pipe
|
||||
// nobody reads, its icon in i3bar's tray, dunst on the bus.
|
||||
func newSlack(t *testing.T) (*fake, map[string]string) {
|
||||
f := newFake(t)
|
||||
links := map[string]string{}
|
||||
readLink = func(p string) (string, error) {
|
||||
rel := strings.TrimPrefix(p, f.Root)
|
||||
if l, ok := links[rel]; ok {
|
||||
return l, nil
|
||||
}
|
||||
return "", errors.New("no such link")
|
||||
}
|
||||
t.Cleanup(func() { readLink = os.Readlink })
|
||||
f.desktopSession()
|
||||
f.write(testHome+"/"+stateFile, rootState)
|
||||
f.write(testHome+"/"+localFile, `{"lastElectronVersionLaunched":"43.4.0","electronFeatureOverrides":["x"]}`)
|
||||
f.write(testHome+"/"+logDir+"/browser.log", browserLog)
|
||||
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n# slack\n"+contribution(t))
|
||||
f.proc(3867, 1000, slackComm, []string{"/usr/lib/slack/slack", "--gtk-version=3", "-s"}, "user@1000.service/app.slice/app-slack-3867.scope")
|
||||
f.proc(3945, 1000, slackComm, []string{"/usr/lib/slack/slack", "--type=zygote"}, "session-c1.scope")
|
||||
links["/proc/3867/fd/1"] = "pipe:[36802]"
|
||||
links["/proc/3867/fd/2"] = "/dev/null"
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch {
|
||||
case name == "pacman" && args[0] == "-Q":
|
||||
return Output{Stdout: "slack-desktop 4.51.191-1\n"}
|
||||
case name == "pacman" && args[0] == "-Qmq":
|
||||
return Output{Stdout: "slack-desktop\n"}
|
||||
case name == "xwininfo":
|
||||
return Output{Stdout: tree}
|
||||
case name == "busctl":
|
||||
return Output{Stdout: ":1.19 3923 dunst operator :1.19 user@1000.service - -\n" +
|
||||
"org.freedesktop.Notifications 3923 dunst operator :1.19 user@1000.service - -\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
return f, links
|
||||
}
|
||||
|
||||
func contribution(t *testing.T) string {
|
||||
m, _ := readManifest(t)
|
||||
return m.Contributions[0].Content
|
||||
}
|
||||
|
||||
func noSecrets(t *testing.T, v any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := string(raw)
|
||||
for _, never := range []string{"xoxc", "xoxd", "AbCdEf", "abc.def", "private message", "Secret Workspace", "TAAAA", "TBBBB",
|
||||
"secret-sso", "/home/operator"} {
|
||||
if strings.Contains(s, never) {
|
||||
t.Errorf("the answer carries %q: %s", never, s)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestStatusCountsWorkspacesSinceTheLastStartAndNamesNone(t *testing.T) {
|
||||
f, _ := newSlack(t)
|
||||
s, err := f.Status()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noSecrets(t, s)
|
||||
if s.Installed != "4.51.191-1" || !s.Foreign || len(s.Running) != 1 || s.Running[0].PID != 3867 || s.Helpers != 1 {
|
||||
t.Fatalf("%+v", s)
|
||||
}
|
||||
// Two accounts since the start of 10/04 (the earlier start's account is not counted), and the
|
||||
// workspaces they open together: TBBBB's two, one shared with TAAAA.
|
||||
if s.Workspaces.Accounts != 2 || s.Workspaces.Workspaces != 2 || s.Workspaces.AsOf != "10/04/26, 23:41:06:340" {
|
||||
t.Fatalf("%+v", s.Workspaces)
|
||||
}
|
||||
st := s.Settings
|
||||
if !st.Found || !*st.LaunchOnLogin || !*st.HideOnStartup || !*st.RunFromTray || st.NotificationMethod != "Slack's default" || st.Electron != "43.4.0" {
|
||||
t.Fatalf("%+v", st)
|
||||
}
|
||||
if s.Tray == nil || !s.Tray.Present || s.Tray.In != "i3bar" {
|
||||
t.Fatalf("%+v", s.Tray)
|
||||
}
|
||||
if s.Notifier.Owner != "dunst" || s.Notifier.PID != 3923 {
|
||||
t.Fatalf("%+v", s.Notifier)
|
||||
}
|
||||
if len(s.Output) != 2 || !s.Output[0].Dead || s.Output[1].Goes != "discarded (/dev/null)" {
|
||||
t.Fatalf("%+v", s.Output)
|
||||
}
|
||||
if len(s.Starts) != 1 || !strings.Contains(s.Starts[0], "this module's line") {
|
||||
t.Fatalf("%q", s.Starts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPipeWithAReaderIsLiveAndTheJournalIsASocket(t *testing.T) {
|
||||
f, links := newSlack(t)
|
||||
f.proc(500, 1000, "systemd-cat", []string{"systemd-cat", "-t", "x-session"}, "session-c1.scope")
|
||||
links["/proc/500/fd/0"] = "pipe:[36802]"
|
||||
f.write("/proc/500/fdinfo/0", "pos:\t0\nflags:\t02000000\nmnt_id:\t15\n")
|
||||
// The writer's own end, elsewhere, is not a reader.
|
||||
f.proc(501, 1000, "sh", []string{"sh"}, "session-c1.scope")
|
||||
links["/proc/501/fd/1"] = "pipe:[36802]"
|
||||
f.write("/proc/501/fdinfo/1", "pos:\t0\nflags:\t01\n")
|
||||
links["/proc/3867/fd/2"] = "socket:[22289]"
|
||||
for _, fd := range []string{"0", "1"} {
|
||||
_ = os.MkdirAll(filepath.Join(f.Root, "/proc/500/fd"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(f.Root, "/proc/500/fd", fd), nil, 0o644)
|
||||
_ = os.MkdirAll(filepath.Join(f.Root, "/proc/501/fd"), 0o755)
|
||||
_ = os.WriteFile(filepath.Join(f.Root, "/proc/501/fd", fd), nil, 0o644)
|
||||
}
|
||||
got := f.streams(3867)
|
||||
if got[0].Dead || got[0].Goes != "a pipe read by systemd-cat (pid 500)" || !strings.HasPrefix(got[1].Goes, "a socket") {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTrayIsAnIconInsideAnotherProgramsWindow(t *testing.T) {
|
||||
if ok, in := trayIn(tree); !ok || in != "i3bar" {
|
||||
t.Fatal(ok, in)
|
||||
}
|
||||
without := strings.Replace(tree, ` 0x2800029 "slack": ("slack" "Slack") 22x22+2832+2 +2832+2`+"\n", "", 1)
|
||||
if ok, _ := trayIn(without); ok {
|
||||
t.Fatal("the top-level Slack windows and the managed chat window are not a tray icon")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLogIsMasked(t *testing.T) {
|
||||
f, _ := newSlack(t)
|
||||
l, err := f.Log("browser", 2000, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all := strings.Join(l.Lines, "\n")
|
||||
for _, never := range []string{"xoxc", "xoxd", "AbCdEf", "abc.def", "private message", "Secret Workspace"} {
|
||||
if strings.Contains(all, never) {
|
||||
t.Errorf("the log carries %q", never)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(all, `"title": "<hidden>"`) || !strings.Contains(all, "token <hidden>") || l.Files[0] != "~/"+logDir+"/browser.log" {
|
||||
t.Fatalf("%+v", l)
|
||||
}
|
||||
p, _ := f.Log("browser", 10, true)
|
||||
if len(p.Lines) != 2 || !strings.Contains(p.Lines[0], "warn:") || !strings.Contains(p.Lines[1], "Authorization: <hidden>") {
|
||||
t.Fatalf("%q", p.Lines)
|
||||
}
|
||||
if _, err := f.Log("cookies", 5, false); err == nil {
|
||||
t.Fatal("an unknown source is refused")
|
||||
}
|
||||
if l, err := newFake(t).Log("webapp", 5, false); err != nil || l.Note == "" {
|
||||
t.Fatalf("%+v %v", l, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestartEndsSlackAndStartsItToTheTray(t *testing.T) {
|
||||
f, _ := newSlack(t)
|
||||
f.onStart = func(argv []string) {
|
||||
f.proc(9000, 1000, slackComm, argv, "user@1000.service/app.slice/"+restartAs+".service")
|
||||
f.proc(9001, 1000, slackComm, []string{"/usr/lib/slack/slack", "--type=zygote"}, "user@1000.service/app.slice/"+restartAs+".service")
|
||||
}
|
||||
a, err := f.Restart()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9000 || a.Running[0].Command != "/usr/bin/slack --gtk-version=3 -s" {
|
||||
t.Fatalf("%+v", a)
|
||||
}
|
||||
if !f.called("systemd-run --user --collect --quiet --unit=mesh-slack --setenv=DISPLAY=:1") {
|
||||
t.Fatalf("%q", f.calls)
|
||||
}
|
||||
f.onStart = nil
|
||||
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u mesh-slack.service") {
|
||||
t.Fatalf("a start that shows no process: %v", err)
|
||||
}
|
||||
none := newFake(t)
|
||||
if _, err := none.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
|
||||
t.Fatalf("without a desktop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckNamesEveryOtherStartTheOperatorsRulesAndTheDeadPipe(t *testing.T) {
|
||||
f, links := newSlack(t)
|
||||
c, err := f.Check()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// As the desktop machine is: the one finding is the session's dead pipe.
|
||||
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "fd 1 is a pipe nobody reads") || len(c.Starts) != 1 {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
links["/proc/3867/fd/1"] = "socket:[1]"
|
||||
if c, _ = f.Check(); !c.OK {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
// As the laptop is before migration: the predecessor's entry, the operator's rules file.
|
||||
f.write(testHome+"/.config/autostart/slack.desktop", "[Desktop Entry]\nExec=/usr/bin/slack --gtk-version=3 -s %U\nHidden=false\n")
|
||||
f.write(testHome+"/.config/i3/config.d/50-slack.conf", "# Slack window rules.\n"+
|
||||
`for_window [class="(?i)^slack$" window_role="browser-window"] move to workspace $ws3`+"\n")
|
||||
f.write(testHome+"/.config/i3/config.d/60-mine.conf", "exec --no-startup-id slack\n")
|
||||
c, _ = f.Check()
|
||||
all := noSecrets(t, c)
|
||||
for _, want := range []string{"a second start: XDG autostart: ~/.config/autostart/slack.desktop", "(the predecessor's)",
|
||||
"a second start: window manager: ~/.config/i3/config.d/60-mine.conf:1", "~/.config/i3/config.d/50-slack.conf repeats Slack's window rules"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no finding %q in %s", want, all)
|
||||
}
|
||||
}
|
||||
if len(c.Starts) != 3 {
|
||||
t.Fatalf("%q", c.Starts)
|
||||
}
|
||||
// Nothing installed, nothing placed, two Slacks, no tray, no notifier.
|
||||
f.write(testHome+"/"+i3Main, "exec --no-startup-id dex --autostart --environment i3\n")
|
||||
f.proc(4000, 1000, slackComm, []string{"/usr/lib/slack/slack"}, "s.scope")
|
||||
f.answer = func(name string, args []string) Output {
|
||||
switch name {
|
||||
case "pacman":
|
||||
return Output{Code: 1}
|
||||
case "xwininfo":
|
||||
return Output{Stdout: strings.Replace(tree, `("slack" "Slack") 22x22`, `("other" "Other") 22x22`, 1)}
|
||||
case "busctl":
|
||||
return Output{Stdout: ":1.19 3923 dunst operator :1.19 user@1000.service - -\n"}
|
||||
}
|
||||
return Output{}
|
||||
}
|
||||
c, _ = f.Check()
|
||||
all = noSecrets(t, c)
|
||||
for _, want := range []string{"is not installed", "from the AUR", "not got the module's start", "not got the module's window rules",
|
||||
"2 Slack main processes run", "icon is in no tray", "nobody shows notifications"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("no finding %q in %s", want, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlacksOwnLaunchOnLoginLinkIsNamedAsItsSetting(t *testing.T) {
|
||||
f, _ := newSlack(t)
|
||||
// Slack's setting makes ~/.config/autostart/slack.desktop a link to the package's entry.
|
||||
f.write(testHome+"/.config/autostart/slack.desktop", "[Desktop Entry]\nExec=/usr/bin/slack --gtk-version=3 -s %U\n")
|
||||
orig := isLink
|
||||
isLink = func(p string) bool { return strings.HasSuffix(p, "/.config/autostart/slack.desktop") }
|
||||
t.Cleanup(func() { isLink = orig })
|
||||
c, _ := f.Check()
|
||||
if !strings.Contains(noSecrets(t, c), "untick 'Launch app on login'") {
|
||||
t.Fatalf("%+v", c)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module slack
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"module": "slack",
|
||||
"version": "1",
|
||||
"requires": [
|
||||
"x11-display"
|
||||
],
|
||||
"tools": [
|
||||
"slack_status",
|
||||
"slack_log",
|
||||
"slack_restart",
|
||||
"slack_check"
|
||||
],
|
||||
"build": {
|
||||
"artifacts": [
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/slack-tools",
|
||||
"binary": "slack-tools",
|
||||
"loads": [
|
||||
"slack-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
"contributions": [
|
||||
{
|
||||
"seat": "node-display-session",
|
||||
"kind": "config",
|
||||
"content": "# Slack (module slack, novox/hq ADR 0208, ADR 0212). Owned by the mesh: replaced at every push.\n# Its one start: at login, to the tray (-s), with the arguments of the package's own desktop entry.\n# No XDG autostart entry starts it as well; slack_check names one if it appears.\nexec --no-startup-id /usr/bin/slack --gtk-version=3 -s\n# The chat window. Slack owns four X windows, and the only one i3 manages has the class \"slack\" in\n# lower case; the three of class \"Slack\" (the packaged entry's StartupWMClass) are its unmanaged\n# helpers and its tray icon, so a rule on class=\"Slack\" matches nothing. window_role keeps a call or\n# screen-share window out of the rules. $ws3 is i3's, in scope here.\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] move to workspace $ws3\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] floating disable\nfor_window [class=\"(?i)^slack$\" window_role=\"browser-window\"] border pixel 2\n"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,242 +0,0 @@
|
||||
// systemctl and journalctl, asked in one scope or the other (novox/hq ADR 0177).
|
||||
//
|
||||
// Who asks. The node tools runtime runs as the operator account, not root (novox/hq ADR 0175 §4),
|
||||
// and launches this bundle as a process of its own (ADR 0188, ADR 0193) with the runtime's words:
|
||||
// HOME, a PATH, MESH_OPERATOR_ACCOUNT and MESH_OPERATOR_HOME — and no session words.
|
||||
//
|
||||
// The system manager is the machine's. Reading it needs nothing; acting on it (start, stop,
|
||||
// restart, enable, disable) is refused by polkit to an account that is not root, so those acts go
|
||||
// through `sudo -n`, as the packet filter's and the intrusion prevention's do, and a refusal is
|
||||
// named by how it failed.
|
||||
//
|
||||
// The user manager is the operator account's own, and this process IS that account. systemctl and
|
||||
// journalctl find it by the account's runtime directory, /run/user/<uid>, which the runtime's
|
||||
// environment does not name; so a user-scope call is given XDG_RUNTIME_DIR and the session bus
|
||||
// there. It answers only while the account's manager runs — a login, or lingering enabled — and
|
||||
// when it does not, that is said, never read as "no units".
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { userInfo } from "node:os";
|
||||
|
||||
export type Scope = "system" | "user";
|
||||
export type Act = "start" | "stop" | "restart" | "enable" | "disable";
|
||||
|
||||
export interface Unit {
|
||||
unit: string;
|
||||
load: string;
|
||||
active: string;
|
||||
sub: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
/** What a command did: its output, its exit status, and the spawn error when it never ran. */
|
||||
export interface Ran {
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
status: number;
|
||||
/** Why it did not run to an answer: the spawn failure's code ("ENOENT" when the program is not
|
||||
* there), or that it was ended for taking too long. */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A command runner, so the verbs can be tested without a service manager. */
|
||||
export type Runner = (cmd: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<Ran>;
|
||||
|
||||
/** How long one systemctl or journalctl may take: below the runtime's thirty-second call limit, so
|
||||
* a manager that hangs is answered as such rather than as a call the runtime gave up on. */
|
||||
export const CALL_TIMEOUT_MS = 20_000;
|
||||
|
||||
export const execRunner: Runner = (cmd, args, env) =>
|
||||
new Promise((resolve) => {
|
||||
execFile(cmd, args, { maxBuffer: 16 * 1024 * 1024, env: env ?? process.env, timeout: CALL_TIMEOUT_MS }, (err, stdout, stderr) => {
|
||||
const e = err as (Error & { code?: unknown; killed?: boolean }) | null;
|
||||
if (e?.killed) {
|
||||
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 124, error: `no answer within ${CALL_TIMEOUT_MS / 1000} s` });
|
||||
return;
|
||||
}
|
||||
if (e && typeof e.code === "string") {
|
||||
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: 127, error: e.code });
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: String(stdout ?? ""), stderr: String(stderr ?? ""), status: e ? (typeof e.code === "number" ? e.code : 1) : 0 });
|
||||
});
|
||||
});
|
||||
|
||||
/** The first line of a unit file the host writes for a module's own process (mesh-host
|
||||
* internal/apply/process.go, unitFor). A unit loaded from a file that begins so is one the mesh
|
||||
* declares, and the host writes it back at its next apply. */
|
||||
export const MESH_UNIT_HEADER = "# Generated by the mesh.";
|
||||
|
||||
/** The acts that change the system manager's state, which polkit keeps from a non-root account. */
|
||||
const ACTS: ReadonlySet<string> = new Set<Act>(["start", "stop", "restart", "enable", "disable"]);
|
||||
|
||||
/** The command as it is run: as given when this process is root or the call only reads, else an
|
||||
* act on the system manager through sudo without a prompt. */
|
||||
export function escalated(cmd: string, args: string[], scope: Scope, uid: number | undefined = process.getuid?.()): [string, string[]] {
|
||||
if (uid === 0 || scope === "user" || cmd !== "systemctl" || !ACTS.has(args[0] ?? "")) return [cmd, args];
|
||||
return ["sudo", ["-n", cmd, ...args]];
|
||||
}
|
||||
|
||||
/** The words that let systemctl and journalctl reach the account's own manager. */
|
||||
export function sessionEnv(uid: number, base: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
const runtime = `/run/user/${uid}`;
|
||||
return { ...base, XDG_RUNTIME_DIR: runtime, DBUS_SESSION_BUS_ADDRESS: `unix:path=${runtime}/bus` };
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
/** The operator account, as the mesh told the runtime. */
|
||||
account: string;
|
||||
/** This process's user id and name. */
|
||||
uid: number;
|
||||
user: string;
|
||||
run?: Runner;
|
||||
/** Reads a unit file, to tell whether the mesh wrote it. */
|
||||
read?: (path: string) => Promise<string>;
|
||||
}
|
||||
|
||||
export class ServiceManager {
|
||||
private readonly o: Options;
|
||||
private readonly run: Runner;
|
||||
private readonly read: (path: string) => Promise<string>;
|
||||
|
||||
constructor(o: Options) {
|
||||
this.o = o;
|
||||
this.run = o.run ?? execRunner;
|
||||
this.read = o.read ?? ((p) => readFile(p, "utf8"));
|
||||
}
|
||||
|
||||
static fromEnv(env: NodeJS.ProcessEnv): ServiceManager {
|
||||
const me = userInfo();
|
||||
return new ServiceManager({ account: env.MESH_OPERATOR_ACCOUNT?.trim() || me.username, uid: me.uid, user: me.username });
|
||||
}
|
||||
|
||||
/** One call to systemctl or journalctl in a scope, failing with what went wrong named. */
|
||||
async call(scope: Scope, cmd: "systemctl" | "journalctl", ...args: string[]): Promise<string> {
|
||||
let env: NodeJS.ProcessEnv | undefined;
|
||||
if (scope === "user") {
|
||||
// The user manager is the account's, and only the account's own process reaches it with
|
||||
// plain --user. The runtime is that account; anything else is a runtime this was not
|
||||
// written for, and is said rather than answered from the wrong manager.
|
||||
if (this.o.user !== this.o.account) {
|
||||
throw new Error(`the user scope is ${this.o.account}'s service manager, and this runs as ${this.o.user}`);
|
||||
}
|
||||
env = sessionEnv(this.o.uid);
|
||||
args = ["--user", ...args];
|
||||
}
|
||||
const [program, argv] = escalated(cmd, args, scope, this.o.uid);
|
||||
const r = await this.run(program, argv, env);
|
||||
if (r.status === 0 && !r.error) {
|
||||
// systemctl answers a user manager it cannot reach on stderr and still exits 0 for some
|
||||
// verbs (list-units among them): that is a failure, not an empty answer.
|
||||
if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(r.stderr)) throw this.unreachable(r.stderr);
|
||||
return r.stdout;
|
||||
}
|
||||
throw this.failure(cmd, program, scope, r);
|
||||
}
|
||||
|
||||
private unreachable(said: string): Error {
|
||||
return new Error(
|
||||
`${this.o.account}'s own service manager does not answer at /run/user/${this.o.uid} — the account has no ` +
|
||||
`session and does not linger (loginctl enable-linger ${this.o.account}): ${firstLine(said)}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** What failed, named by how it failed: sudo missing is a spawn error, sudo refusing speaks on its
|
||||
* own stderr line, polkit refusing says so, an unreachable user manager says so, and the rest is
|
||||
* the tool's own last line. */
|
||||
private failure(cmd: string, program: string, scope: Scope, r: Ran): Error {
|
||||
const said = `${r.stderr}\n${r.stdout}`.trim();
|
||||
if (r.error === "ENOENT") {
|
||||
return program === "sudo"
|
||||
? new Error(`${cmd} needs root for this, and sudo is not installed here for the runtime's account to escalate with`)
|
||||
: new Error(`${cmd} is not installed on this machine`);
|
||||
}
|
||||
if (r.error) return new Error(`${cmd} did not answer: ${r.error}`);
|
||||
if (program === "sudo" && /^sudo:/m.test(said)) {
|
||||
return new Error(`${cmd} needs root for this and the runtime's account may not run it without a prompt: ${firstLine(said)}`);
|
||||
}
|
||||
if (/interactive authentication/i.test(said)) {
|
||||
return new Error(`the service manager refused the runtime's account: ${firstLine(said)}`);
|
||||
}
|
||||
if (scope === "user" && /Failed to connect to (user scope )?bus/i.test(said)) return this.unreachable(said);
|
||||
const lines = said.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
return new Error(lines.length ? `${cmd} failed (${r.status}): ${lines[0]}` : `${cmd} failed with status ${r.status}`);
|
||||
}
|
||||
|
||||
async units(scope: Scope, pattern?: string): Promise<Unit[]> {
|
||||
const args = ["list-units", "--all", "--no-legend", "--plain", "--no-pager"];
|
||||
if (pattern) args.push("--", pattern);
|
||||
const stdout = await this.call(scope, "systemctl", ...args);
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)
|
||||
.map((l) => {
|
||||
const [unit, load, active, sub, ...rest] = l.split(/\s+/);
|
||||
return { unit, load, active, sub, description: rest.join(" ") };
|
||||
});
|
||||
}
|
||||
|
||||
/** One unit's state, and whether the mesh declares it.
|
||||
*
|
||||
* **Declared** is read from the unit file systemd loaded (FragmentPath): the host writes every
|
||||
* unit of a module's own process whole, under its own header, and writes it back at its next
|
||||
* apply. That is the case a person's act is undone in, so it is the one the answer must name.
|
||||
* A unit the mesh only puts into a state through the `service` shape — a package's own unit —
|
||||
* carries no mark, and the host's record of it is root's; such a unit answers false here. */
|
||||
async status(scope: Scope, unit: string): Promise<Record<string, string | boolean>> {
|
||||
const props = ["LoadState", "ActiveState", "SubState", "UnitFileState", "MainPID", "ExecMainStatus", "Description", "FragmentPath"];
|
||||
const stdout = await this.call(scope, "systemctl", "show", unitArg(unit), "--no-pager", ...props.map((p) => `--property=${p}`));
|
||||
const out: Record<string, string | boolean> = { unit, scope };
|
||||
for (const line of stdout.split("\n")) {
|
||||
const i = line.indexOf("=");
|
||||
if (i > 0) out[line.slice(0, i)] = line.slice(i + 1);
|
||||
}
|
||||
out.mesh_declared = await this.writtenByMesh(String(out.FragmentPath ?? ""));
|
||||
return out;
|
||||
}
|
||||
|
||||
private async writtenByMesh(path: string): Promise<boolean> {
|
||||
if (!path) return false;
|
||||
const text = await this.read(path).catch(() => "");
|
||||
return text.startsWith(MESH_UNIT_HEADER);
|
||||
}
|
||||
|
||||
async act(scope: Scope, verb: Act, unit: string): Promise<Record<string, unknown>> {
|
||||
await this.call(scope, "systemctl", verb, unitArg(unit));
|
||||
const after = await this.status(scope, unit);
|
||||
const answer: Record<string, unknown> = { unit, scope, verb, ok: true, active: after.ActiveState, boot: after.UnitFileState, mesh_declared: after.mesh_declared };
|
||||
if (after.mesh_declared) answer.note = "the mesh declares this unit: the host restores its declared state at its next apply";
|
||||
return answer;
|
||||
}
|
||||
|
||||
async journal(scope: Scope, unit: string, lines: number): Promise<{ unit: string; scope: Scope; lines: string[] }> {
|
||||
const stdout = await this.call(scope, "journalctl", "--no-pager", "-n", String(lines), "-u", unitArg(unit), "-o", "short-iso");
|
||||
return { unit, scope, lines: stdout.split("\n").filter(Boolean) };
|
||||
}
|
||||
|
||||
/** Every failed unit in both managers. A manager that does not answer is reported as such,
|
||||
* beside the other's answer — never as "nothing failed". */
|
||||
async failed(): Promise<{ system: Unit[] | { error: string }; user: Unit[] | { error: string } }> {
|
||||
const failedIn = async (scope: Scope) => {
|
||||
try {
|
||||
return (await this.units(scope)).filter((u) => u.active === "failed");
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
};
|
||||
return { system: await failedIn("system"), user: await failedIn("user") };
|
||||
}
|
||||
}
|
||||
|
||||
/** A unit's name as an argument: never something systemctl or journalctl would read as an option,
|
||||
* which under sudo would be root's option. */
|
||||
export function unitArg(unit: string): string {
|
||||
if (!unit || unit.startsWith("-") || /[\s\0]/.test(unit)) throw new Error(`${JSON.stringify(unit)} is not a unit's name`);
|
||||
return unit;
|
||||
}
|
||||
|
||||
function firstLine(text: string): string {
|
||||
return text.split("\n").map((l) => l.trim()).find(Boolean) ?? "";
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user