Merge pull request 'nextcloud-client and blueman: the two tray apps as modules, each with one start' (#48) from feat/nextcloud-client-and-blueman into main

This commit was merged in pull request #48.
This commit is contained in:
2026-10-05 09:48:43 +00:00
23 changed files with 3654 additions and 2 deletions
+82
View File
@@ -0,0 +1,82 @@
# blueman
The Bluetooth 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, the manager window, send-to | package `blueman`, 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.
- **The Bluetooth stack is not this module's.** `bluez`, `bluez-utils` and `bluetooth.service` belong
to the `bluetooth` module. This module declares none of them (ADR 0210 §4: one package, one module).
The devices, pairing and power are that module's tools (`bluetooth_*`). This module's tools are
about the applet.
- **The operator's applet settings are found.** blueman keeps them in dconf (`org.blueman.*`): the
plugin switches, the recent connections, auto-connect and auto-power-on. The module neither sets nor
resets them. `blueman_status` shows the plugin switches.
## How it starts: the package's autostart entry, and nothing else
One process has one starter (the rule `picom` states for the desktop modules). The package ships
`/etc/xdg/autostart/blueman.desktop` (`blueman-applet`). 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 … blueman-applet`, which the `i3` module's configuration
dropped.
- **Not a start:** the package's user unit `blueman-applet.service` is static. It exists for D-Bus
activation of `org.blueman.Applet`. A program that calls the applet's bus name while no applet runs
starts one through it. The applet is single-instance on that name, so this never makes a second
one. The tools always ask the bus with `--auto-start=no`, so asking never starts it.
- The applet starts its tray icon, `blueman-tray`, itself.
## Tools
They are served by the node's runtime as the operator account (ADR 0175).
| tool | does |
|---|---|
| `blueman_status` (r) | <ul><li>whether the applet and its tray icon run: pid, since, and the scope or unit they run in</li><li>the installed version, and what starts it at login</li><li>the plugins the running applet has loaded and those it has not (asked of the applet on the session bus)</li><li>the plugin switches in `org.blueman.general plugin-list`</li><li>whether the applet sees Bluetooth on</li></ul> |
| `blueman_restart` (a) | asks the applet and the tray icon to end (SIGTERM), forces them after 5 s, and starts `blueman-applet` in the operator's session. The start is a transient user unit `mesh-blueman-applet`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
| `blueman_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>`bluetooth.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 `clipmenu` and `screen-lock` 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
| | g14 | shanks |
|---|---|---|
| package | none: `blueman` 2.4.6 is installed, explicitly, from `extra` | the same |
| start | none: dex starts the applet from the package's entry, in the login session's scope; the tray icon with it | none on disk. **The applet running now came from the predecessor's window-manager line** (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 |
| settings (dconf) | defaults, no plugin switched; recent connections only | no plugin switched; auto-connect for one headset, auto-power-on set |
The workstations are already in the state this module describes.
## Migration (ADR 0182)
Nothing is required on either machine. On shanks, log out and in once, or run `blueman_restart`, and
the applet runs from its one start. `blueman_check` then answers `ok`.
## Leaves as found
- The applet's dconf settings (`/org/blueman/`).
- `/etc/xdg/autostart/blueman.desktop`, the package's own file.
## Relies on
- **The `bluetooth` module, for bluez and its daemon.** Without them the applet has nothing to
manage. There is no dependency mechanism between two modules that hold no seat. So nothing refuses
`blueman` on a node without `bluetooth`. `blueman_check` reports it instead, from
`bluetooth.service`. **Assign both.** When the stack becomes a seat (`node-bluetooth`), this module
depends on the seat.
- **`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. `blueman_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3).
+97
View File
@@ -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,221 @@
package main
// blueman's applet as the tools see it: its processes, its XDG autostart entry (the package's), the
// applet's own answers on the session bus, and its settings in gsettings. Every bus call is made with
// --auto-start=no: the applet is D-Bus activatable, and a question must never start it.
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
)
const (
appletComm = "blueman-applet"
trayComm = "blueman-tray"
appletBin = "/usr/bin/blueman-applet"
entryName = "blueman.desktop"
restartAs = "mesh-blueman-applet"
packageFor = "blueman"
busName = "org.blueman.Applet"
busPath = "/org/blueman/Applet"
stackUnit = "bluetooth.service"
)
// appletCall asks the running applet one question over the session bus and answers busctl's JSON.
func (m *Machine) appletCall(method string) (json.RawMessage, error) {
args := []string{"--user", "--auto-start=no", "--json=short", "call", busName, busPath, busName, 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 applet's answer to %s is not busctl's JSON: %q", method, tail(o.Stdout, 200))
}
return doc.Data[0], nil
}
func (m *Machine) appletStrings(method string) ([]string, error) {
raw, err := m.appletCall(method)
if err != nil {
return nil, err
}
var out []string
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("the applet's %s is not a list of names: %w", method, err)
}
sort.Strings(out)
return out, nil
}
// gvariantStrings reads gsettings' printed array of strings: "@as []" or "['a', '!b']".
func gvariantStrings(s string) []string {
s = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(s), "@as"))
s = strings.TrimSuffix(strings.TrimPrefix(s, "["), "]")
out := []string{}
for _, item := range strings.Split(s, ",") {
item = strings.Trim(strings.TrimSpace(item), `'"`)
if item != "" {
out = append(out, item)
}
}
return out
}
// Plugins is which applet plugins run, and what the operator's settings say about them.
type Plugins struct {
// Loaded are the plugins the running applet answers it has loaded.
Loaded []string `json:"loaded"`
// NotLoaded are the plugins it knows but did not load: switched off, or not fit for this machine.
NotLoaded []string `json:"not_loaded"`
// Switched is the operator's plugin-list setting: a name to load it, !name to keep it off. Empty
// is blueman's defaults.
Switched []string `json:"switched"`
}
// AppletStatus is what blueman_status answers.
type AppletStatus struct {
Installed string `json:"installed,omitempty"`
Applet []Proc `json:"applet"`
Tray []Proc `json:"tray"`
StartedBy Autostart `json:"started_by"`
// Bluetooth is the applet's own view of the adapter's power, when it runs.
Bluetooth *bool `json:"bluetooth_on,omitempty"`
Plugins Plugins `json:"plugins"`
// Unanswered says why the running applet's view is missing.
Unanswered string `json:"unanswered,omitempty"`
}
// Status reads the applet: running or not, how it starts, and its plugins.
func (m *Machine) Status() (AppletStatus, error) {
s := AppletStatus{Applet: m.procs(appletComm), Tray: m.procs(trayComm), StartedBy: m.autostart(entryName),
Plugins: Plugins{Loaded: []string{}, NotLoaded: []string{}, Switched: []string{}}}
if s.Applet == nil {
s.Applet = []Proc{}
}
if s.Tray == nil {
s.Tray = []Proc{}
}
if v, err := m.installed(packageFor); err == nil {
s.Installed = v
}
o := m.cmd(5*time.Second, m.bus(), "gsettings", "get", "org.blueman.general", "plugin-list")
if o.Err == nil && o.Code == 0 {
s.Plugins.Switched = gvariantStrings(o.Stdout)
}
if len(s.Applet) == 0 {
s.Unanswered = "the applet is not running"
return s, nil
}
loaded, err := m.appletStrings("QueryPlugins")
if err != nil {
s.Unanswered = err.Error()
return s, nil
}
s.Plugins.Loaded = loaded
if all, err := m.appletStrings("QueryAvailablePlugins"); err == nil {
in := map[string]bool{}
for _, p := range loaded {
in[p] = true
}
for _, p := range all {
if !in[p] {
s.Plugins.NotLoaded = append(s.Plugins.NotLoaded, p)
}
}
}
if raw, err := m.appletCall("GetBluetoothStatus"); err == nil {
var on bool
if json.Unmarshal(raw, &on) == nil {
s.Bluetooth = &on
}
}
return s, nil
}
// RestartAnswer is what blueman_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 its tray icon, and starts the applet again in the operator's session
// under the account's service manager. The applet starts its tray icon itself.
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, trayComm)
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 blueman_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 the
// Bluetooth daemon it manages, which is the bluetooth module's.
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 blueman 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("the Bluetooth daemon ("+stackUnit+") is not running: the applet has nothing to manage",
"assign the bluetooth module, which owns bluez and its daemon")
}
running := m.procs(appletComm)
if _, err := m.session(); err == nil {
switch {
case len(running) == 0:
add("no applet runs in the desktop session", "blueman_restart")
case len(running) > 1:
add(fmt.Sprintf("%d applets run", len(running)), "blueman_restart ends them all and starts one")
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,126 @@
package main
import (
"strings"
"testing"
)
func newApplet(t *testing.T, running bool) *fake {
f := newFake(t)
f.write("/etc/xdg/autostart/blueman.desktop", "[Desktop Entry]\nName=Blueman Applet\nExec=blueman-applet\nType=Application\n")
if running {
f.proc(3859, 1000, appletComm, []string{"/usr/bin/python", "/usr/bin/blueman-applet"}, "session-c1.scope")
f.proc(4084, 1000, trayComm, []string{"/usr/bin/python", "/usr/bin/blueman-tray"}, "session-c1.scope")
}
f.answer = func(name string, args []string) Output {
call := name + " " + strings.Join(args, " ")
switch {
case name == "pacman":
return Output{Stdout: "blueman 2.4.6-2\n"}
case name == "gsettings":
return Output{Stdout: "['!NetUsage', 'DhcpClient']\n"}
case strings.HasSuffix(call, " QueryPlugins"):
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager"]]}`}
case strings.HasSuffix(call, " QueryAvailablePlugins"):
return Output{Stdout: `{"type":"as","data":[["StatusIcon","AuthAgent","PowerManager","NetUsage"]]}`}
case strings.HasSuffix(call, " GetBluetoothStatus"):
return Output{Stdout: `{"type":"b","data":[true]}`}
case name == "systemctl" && len(args) > 1 && args[0] == "is-active":
return Output{Stdout: "active\n"}
}
return Output{}
}
return f
}
func TestStatusAsksTheRunningAppletAndNeverStartsIt(t *testing.T) {
f := newApplet(t, true)
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
if s.Installed != "2.4.6-2" || len(s.Applet) != 1 || len(s.Tray) != 1 || !s.StartedBy.Starts || s.Bluetooth == nil || !*s.Bluetooth {
t.Fatalf("%+v", s)
}
if strings.Join(s.Plugins.Loaded, ",") != "AuthAgent,PowerManager,StatusIcon" || strings.Join(s.Plugins.NotLoaded, ",") != "NetUsage" ||
strings.Join(s.Plugins.Switched, ",") != "!NetUsage,DhcpClient" {
t.Fatalf("%+v", s.Plugins)
}
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 applet: %s", c)
}
}
stopped := newApplet(t, false)
s, err = stopped.Status()
if err != nil || s.Unanswered != "the applet is not running" || len(s.Applet) != 0 || s.Bluetooth != nil {
t.Fatalf("%+v %v", s, err)
}
if stopped.called("busctl") {
t.Fatalf("asked the bus with no applet running: %q", stopped.calls)
}
}
func TestSettingsListsAreReadAsGSettingsPrintsThem(t *testing.T) {
for in, want := range map[string]string{"@as []\n": "", "['a']": "a", "['!a', 'b']\n": "!a,b"} {
if got := strings.Join(gvariantStrings(in), ","); got != want {
t.Errorf("%q: %q, not %q", in, got, want)
}
}
}
func TestRestartEndsAppletAndTrayAndStartsTheApplet(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 {
t.Fatal(err)
}
if len(a.Ended) != 2 || len(a.Running) != 1 || a.Running[0].PID != 9100 || a.Running[0].Command != appletBin {
t.Fatalf("%+v", a)
}
if !f.called("systemd-run --user --collect --quiet --unit=" + restartAs + " --setenv=DISPLAY=:1") {
t.Fatalf("%q", f.calls)
}
}
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/blueman.desktop" {
t.Fatalf("%+v %v", c, err)
}
f.write(testHome+"/.config/i3/config", "exec --no-startup-id blueman-applet\n")
f.write(testHome+"/.config/autostart/blueman.desktop", "[Desktop Entry]\nExec=blueman-applet\nHidden=true\n")
f.proc(3860, 1000, appletComm, []string{"blueman-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}
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", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
"bluetooth.service", "2 applets run"} {
if !strings.Contains(got, want) {
t.Errorf("no finding %q in\n%s", want, got)
}
}
}
@@ -0,0 +1,574 @@
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).
//
// 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
}
// 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 pids []int
for _, c := range comms {
for _, p := range m.procs(c) {
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 {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); 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,202 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
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 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))
}
}
+48
View File
@@ -0,0 +1,48 @@
// The blueman module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the Bluetooth 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. The devices themselves are the bluetooth 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: "blueman_status",
Description: "The Bluetooth applet: whether it and its tray icon run (pid, since, and the unit or " +
"session scope they run in), the installed version, what starts it at login, the plugins the " +
"running applet has loaded and those it has not, the plugin switches in the operator's settings, " +
"and whether the applet sees Bluetooth on. Never starts the applet. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "blueman_restart",
Description: "End the applet and its tray icon (asked first, then forced after 5 s) and start the " +
"applet 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: "blueman_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 the Bluetooth daemon is running " +
"(the bluetooth 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,109 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// blueman's shape (novox/hq ADR 0208, ADR 0210): 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
// bluetooth module's (bluez, its utilities, its daemon), and the Go bundle serving exactly the listed
// blueman_ 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 []any `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 TestItInstallsTheAppletAndNothingElse(t *testing.T) {
m, _ := readManifest(t)
if m.Module != "blueman" || !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 {
t.Fatal("it holds no seat and sets no environment")
}
}
func TestItAddsNoSecondStartAndDeclaresNothingOfTheBluetoothModule(t *testing.T) {
m, raw := readManifest(t)
// The package ships its XDG autostart entry, which the session's dex runs: an xinitrc slot or a
// window-manager exec would start it twice.
if m.Shell != nil || m.Contributions != nil {
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
}
for _, never := range []string{"autostart", "service", "bluez", "/etc/bluetooth"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q: the start is the package's, the stack the bluetooth module's", never)
}
}
}
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, "blueman_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed blueman_ 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/blueman-tools" || b["binary"] != "blueman-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)
}
}
}
+5
View File
@@ -0,0 +1,5 @@
module blueman
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+37
View File
@@ -0,0 +1,37 @@
{
"module": "blueman",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"blueman_status",
"blueman_restart",
"blueman_check"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "blueman"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/blueman-tools",
"binary": "blueman-tools",
"loads": [
"blueman-tools"
]
}
]
}
}
+2 -2
View File
@@ -48,6 +48,6 @@ running. `bluez` becomes explicitly the mesh's.
## Leaves as found
- The paired devices and their keys under `/var/lib/bluetooth` (bluez's state).
- `blueman` on both workstations, and its applet, which the window manager's configuration starts.
That line is the `i3` module's to keep or drop.
- `blueman` and its applet: the `blueman` module's, which relies on this one for the stack and
declares none of its packages. The applet starts from the package's XDG autostart entry.
- `bluez-obex` and the AUR terminal client `bluetuith-bin` (with its `-debug`) on the laptop.
+106
View File
@@ -0,0 +1,106 @@
# nextcloud-client
The Nextcloud desktop sync client 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 client | package `nextcloud-client`, from the official repositories |
Nothing else. It holds no seat, makes no contribution and writes no file.
- **No AUR, no vendored copy.** Both workstations run the official package (`extra`), installed
explicitly. ADR 0205 does not apply.
- **The account configuration stays the operator's.** `~/.config/Nextcloud/nextcloud.cfg` is the
client's own file, and the client rewrites it. That makes it *found* in ADR 0182's terms: the module
never declares, reads into or writes it. The tools only read it. The accounts, the sync folders, the
server and the credentials are set in the client.
## How it starts: the client's own autostart entry, and nothing else
One process has one starter (the rule `picom` states for the desktop modules). The client's starter is
**its own XDG autostart entry**, `~/.config/autostart/Nextcloud.desktop` (`nextcloud --background`).
- The client writes that entry itself while its setting *Launch on system startup* is ticked, and
removes it when the setting is unticked.
- The session runs every XDG autostart entry once at login: the `i3` module's
`dex --autostart --environment i3`.
- The package ships no `/etc/xdg/autostart` entry.
**Why not a contribution to `node-display-session` or the `xinitrc` slot:** the client would still
write its own entry whenever the setting is ticked, and the session would start it twice. The module
cannot own the entry either, because the client rewrites it on every start. Declaring that file would
make two writers of one file. So the module adds no start, and `nextcloud_check` holds the rule
instead: it names any second start it finds.
- **Excluded:** the window manager's `exec … nextcloud` (the `i3` module's configuration dropped it),
and the package's user unit `com.nextcloud.desktopclient.nextcloud.service`, which stays disabled.
User-scoped units are not declarable yet (mesh-host #72).
- **One caveat:** `dex` ignores the entry's `X-GNOME-Autostart-Delay=10`, so the client starts with
the session. It retries its connection by itself, so that is harmless.
## Tools
They are served by the node's runtime as the operator account (ADR 0175), and are read-only except
`restart`. **No answer carries the server's address, the account's user ids or a credential.**
- `nextcloud.cfg` is read only to learn what to hide.
- The log tools replace the server's host with `<server>` and the user ids with `<account>`.
- Anything shaped like a credential (`Authorization:`, `token=`, `password=`, a cookie) becomes `<hidden>`.
| tool | does |
|---|---|
| `nextcloud_status` (r) | <ul><li>whether the client runs: pid, since, and the scope or unit it runs in</li><li>the installed version, and what starts it at login</li><li>each account by display name and auth type, with each sync folder: local path (`~/…`), remote path, paused, virtual files, journal present</li><li>each folder's **last sync run**: started, finished or still running, items, errors, the first ten failing files</li><li>the latest warnings and worse in the client's log</li></ul> |
| `nextcloud_log` (r) | the last `lines` (default 100, at most 2000) of the client's log (`source: client`). The log rotates every two hours, and older gzipped files are read until the count is reached. `problems: true` keeps warnings and worse. `source: sync` gives the sync runs' log. Answers are capped at 256 KiB |
| `nextcloud_restart` (a) | asks the client to end (SIGTERM), forces it after 6 s, and starts `nextcloud --background` in the operator's session. The start is a transient user unit `mesh-nextcloud-client`, so it outlives the tools runtime. Answers the pids. Refused plainly when nobody is logged in to the desktop |
| `nextcloud_check` (r) | <ul><li>the package is installed</li><li>exactly one start: the entry is present and enabled, and `dex` is installed</li><li>no window-manager exec and no enabled user unit</li><li>one client runs in a desktop session</li><li>an account exists, its folders exist with a journal, and none is paused</li></ul>Each finding says what to do |
**Where the tools read:**
- The client's settings are read from `~/.config/Nextcloud/nextcloud.cfg`.
- Its log is read from `~/.config/Nextcloud/logs/*_nextcloud.log*`.
- Each folder's sync runs are read from the `*_sync.log` whose first line is that folder's path. That
file is in `~/.local/share/Nextcloud/`, or in `~/.config/Nextcloud/` for older clients, and the
newest one wins.
The tools find the session's `DISPLAY` and `XAUTHORITY` from the window manager's own environment,
as `clipmenu` and `screen-lock` 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
| | g14 | shanks |
|---|---|---|
| package | none: `nextcloud-client` 34.0.4 is installed, explicitly, from `extra` | the same |
| start | none: dex starts it from the client's own entry (`--background`, in the login session's scope) | none on disk. **The client running now came from the predecessor's window-manager line** (`nextcloud`, 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 its entry |
| settings | one account, one folder (`~/Nextcloud/`, whole server), not paused, no virtual files; *Launch on system startup* on | the same |
The workstations are already in the state this module describes.
## Migration (ADR 0182)
Nothing is required on either machine.
- **shanks:** log out and in once, or run `nextcloud_restart`, and the client runs from its one start.
`nextcloud_check` then answers `ok`.
- **Optional, both:** the client has kept a `nextcloud.cfg.backup_<date>_<version>` from every upgrade
since 2023 (about twenty on each machine). It also keeps a sync log that it no longer writes, at
`~/.config/Nextcloud/Nextcloud_sync.log`, from 2024 on g14 and 2023 on shanks. They are the
operator's to delete. The module leaves them.
## Leaves as found
- `~/.config/Nextcloud/`: the settings, their backups, `cookies0.db`, `sync-exclude.lst`, the logs.
- `~/.local/share/Nextcloud/`: the sync runs' log.
- `~/.config/autostart/Nextcloud.desktop`, the client's.
- Every sync folder and its `.sync_*.db` journal.
## Relies on
- **`i3`'s `dex` line for the start.** Nothing in the mesh says so yet: XDG autostart has no seat, and
a module without a seat or contribution has no way to depend on another module. Assigned without
`i3`, the client is installed and does not start. `nextcloud_check` says so.
- A display server on the same machine (`x11-display`, ADR 0208 §3). Under sway the client runs on
Wayland as well. A Wayland twin then requires `wayland-display`.
@@ -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,574 @@
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).
//
// 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
}
// 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 pids []int
for _, c := range comms {
for _, p := range m.procs(c) {
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 {
step := 250 * time.Millisecond
for waited := time.Duration(0); ; waited += step {
if p := m.procs(comm); 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,202 @@
package main
// The fake machine the tests run against, and the tests of desktop.go. The same in the
// nextcloud-client and blueman bundles.
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 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,81 @@
// The nextcloud-client module's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): the
// Nextcloud desktop sync client 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 client's account configuration is the operator's: the tools read the client's own files and
// never write them, and no answer carries the server's address, the account's ids or a credential.
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: "nextcloud_status",
Description: "The Nextcloud desktop client: whether it runs (pid, since, and the unit or session scope " +
"it runs in), the installed version, what starts it at login, each account by name (never the " +
"server's address or a credential) with its sync folders' local paths, remote paths, paused, and " +
"the last sync run (started, finished, items, errors and the first failing files), and the latest " +
"warnings in the client's log. (r)",
Run: func(map[string]any) (any, error) { return machine.Status() },
},
{
Name: "nextcloud_log",
Description: "The last lines of the client's log (source client, the default) or of the sync runs' " +
"log (source sync, file by file), newest last. With problems, only warnings and worse. The " +
"server's address and the account's ids are replaced by <server> and <account>. (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{"client", "sync"}, "description": "client (default) or sync"},
"problems": map[string]any{"type": "boolean", "description": "only warning, critical and fatal lines of the client's log (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 = "client"
}
problems, err := flag(args, "problems", false)
if err != nil {
return nil, err
}
return machine.Log(source, n, problems)
},
},
{
Name: "nextcloud_restart",
Description: "End the running client (asked first, then forced after 6 s) and start it again in the " +
"operator's desktop session, in the tray, 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: "nextcloud_check",
Description: "Check what the module promises: the package is installed; the client has exactly one " +
"start (its own XDG autostart entry, which the session's dex runs; no window-manager exec, no " +
"enabled user unit); it runs once in a desktop session; it has an account and its folders exist " +
"and are not paused. Answers ok and each finding with what to do. (r)",
Run: func(map[string]any) (any, error) { return machine.Check() },
},
}
}
@@ -0,0 +1,108 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
)
// nextcloud-client's shape (novox/hq ADR 0208, ADR 0210, ADR 0182): one official package, no seat,
// the X display on its own machine, no start of its own (the client's own autostart entry is the one
// start), no file of the client's, and the Go bundle serving exactly the listed nextcloud_ 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 []any `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 TestItInstallsTheClientAndNothingElse(t *testing.T) {
m, _ := readManifest(t)
if m.Module != "nextcloud-client" || !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 {
t.Fatal("it holds no seat and sets no environment")
}
}
func TestItAddsNoSecondStartAndOwnsNoneOfTheClientsFiles(t *testing.T) {
m, raw := readManifest(t)
// The client writes its own XDG autostart entry, which the session's dex runs: an xinitrc slot
// or a window-manager exec would start it twice.
if m.Shell != nil || m.Contributions != nil {
t.Fatalf("a second start: shell %v, contributions %v", m.Shell, m.Contributions)
}
for _, never := range []string{"nextcloud.cfg", "autostart", "service"} {
if strings.Contains(raw, never) {
t.Errorf("module.json names %q: the client's files and start are its own", never)
}
}
}
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, "nextcloud_") || strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s: prefixed nextcloud_ 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/nextcloud-client-tools" || b["binary"] != "nextcloud-client-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,667 @@
package main
// The Nextcloud desktop client as the tools see it, read from the client's own files only:
// - ~/.config/Nextcloud/nextcloud.cfg, the client's settings (Qt's INI form), which the client
// rewrites and the module never writes. Accounts and sync folders are read from it. The server's
// address, the account's user ids and every credential-like key are never answered: they are
// read only to be hidden in what the log tools answer;
// - the sync-run log of each folder (<appdata>/Nextcloud/*_sync.log), whose first line is the
// folder's local path, and whose run markers and per-file lines give the last sync's state and
// errors;
// - the client's log directory, ~/.config/Nextcloud/logs, rotated by the client every two hours
// (the newest file plain, the older ones gzipped).
import (
"bufio"
"bytes"
"compress/gzip"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// Where the client keeps things, under the operator's home.
const (
configFile = ".config/Nextcloud/nextcloud.cfg"
logDir = ".config/Nextcloud/logs"
entryName = "Nextcloud.desktop"
clientComm = "nextcloud"
clientBin = "/usr/bin/nextcloud"
restartAs = "mesh-nextcloud-client"
packageFor = "nextcloud-client"
userUnit = "com.nextcloud.desktopclient.nextcloud.service"
)
// syncLogDirs are where the client has kept its sync-run logs, newest convention first.
var syncLogDirs = []string{".local/share/Nextcloud", ".config/Nextcloud"}
// ini is a Qt INI file: section → key → value. Keys keep Qt's backslash-separated groups.
type ini map[string]map[string]string
func parseINI(raw []byte) ini {
out := ini{}
section := "General"
s := bufio.NewScanner(bytes.NewReader(raw))
s.Buffer(make([]byte, 64<<10), 4<<20) // a certificate is one long line
for s.Scan() {
l := strings.TrimSpace(s.Text())
if l == "" || strings.HasPrefix(l, ";") || strings.HasPrefix(l, "#") {
continue
}
if strings.HasPrefix(l, "[") && strings.HasSuffix(l, "]") {
section = l[1 : len(l)-1]
continue
}
i := strings.IndexByte(l, '=')
if i <= 0 {
continue
}
v := strings.TrimSpace(l[i+1:])
if len(v) >= 2 && v[0] == '"' && v[len(v)-1] == '"' {
v = v[1 : len(v)-1]
}
if out[section] == nil {
out[section] = map[string]string{}
}
out[section][strings.TrimSpace(l[:i])] = v
}
return out
}
// Folder is one sync folder as the client is configured.
type Folder struct {
LocalPath string `json:"-"`
Local string `json:"local_path"`
RemotePath string `json:"remote_path"`
Paused bool `json:"paused"`
VirtualFiles string `json:"virtual_files,omitempty"`
Journal string `json:"-"`
JournalFound bool `json:"journal_found"`
LastSync *Run `json:"last_sync,omitempty"`
}
// Account is one account, by name only.
type Account struct {
ID string `json:"id"`
Name string `json:"name"`
Auth string `json:"auth,omitempty"`
Folders []Folder `json:"folders"`
// hidden are the values never answered: the server's host and the account's user ids.
hosts []string
users []string
}
// Config is what the tools read from nextcloud.cfg.
type Config struct {
Found bool `json:"found"`
ClientVersion string `json:"client_version,omitempty"`
LaunchAtStartup *bool `json:"launch_on_system_startup,omitempty"`
Accounts []Account `json:"accounts"`
}
func (m *Machine) config() (Config, error) {
raw, err := readBounded(m.home(configFile))
if os.IsNotExist(err) {
return Config{Accounts: []Account{}}, nil
}
if err != nil {
return Config{}, fmt.Errorf("reading the client's settings: %w", err)
}
f := parseINI(raw)
c := Config{Found: true, ClientVersion: f["General"]["clientVersion"], Accounts: []Account{}}
if v, ok := f["General"]["launchOnSystemStartup"]; ok {
b := v == "true"
c.LaunchAtStartup = &b
}
byID := map[string]*Account{}
folders := map[string]map[string]*Folder{}
var ids []string
for k, v := range f["Accounts"] {
parts := strings.Split(k, `\`)
if len(parts) < 2 {
continue
}
id := parts[0]
if _, err := strconv.Atoi(id); err != nil {
continue
}
a := byID[id]
if a == nil {
a = &Account{ID: id, Folders: []Folder{}}
byID[id] = a
folders[id] = map[string]*Folder{}
ids = append(ids, id)
}
if len(parts) == 2 {
switch parts[1] {
case "displayName":
a.Name = v
case "authType":
a.Auth = v
case "url":
if u, err := url.Parse(v); err == nil && u.Host != "" {
a.hosts = append(a.hosts, u.Host)
if u.Hostname() != u.Host {
a.hosts = append(a.hosts, u.Hostname())
}
}
case "user", "dav_user", "webflow_user", "http_user":
if v != "" {
a.users = append(a.users, v)
}
}
continue
}
// <id>\Folders\<n>\<key>, and the other folder groups (FoldersWithPlaceholders, Multifolders).
if len(parts) == 4 && strings.HasPrefix(parts[1], "Folders") || len(parts) == 4 && parts[1] == "Multifolders" {
key := parts[1] + `\` + parts[2]
fo := folders[id][key]
if fo == nil {
fo = &Folder{}
folders[id][key] = fo
}
switch parts[3] {
case "localPath":
fo.LocalPath = v
case "targetPath":
fo.RemotePath = v
case "paused":
fo.Paused = v == "true"
case "virtualFilesMode":
fo.VirtualFiles = v
case "journalPath":
fo.Journal = v
}
}
}
sort.Strings(ids)
for _, id := range ids {
a := byID[id]
if a.Name == "" {
a.Name = "account " + id
}
var keys []string
for k := range folders[id] {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fo := *folders[id][k]
if fo.LocalPath == "" {
continue
}
fo.Local = m.tilde(fo.LocalPath)
if fo.Journal != "" {
fo.JournalFound = exists(filepath.Join(m.Root, fo.LocalPath, fo.Journal))
}
a.Folders = append(a.Folders, fo)
}
c.Accounts = append(c.Accounts, *a)
}
return c, nil
}
// redactor hides what an answer must never carry: the servers' hosts, the accounts' user ids, and
// anything shaped like a credential.
type redactor struct{ hosts, users []string }
var credential = regexp.MustCompile(`(?i)\b(authorization|bearer|basic|token|apppassword|app_password|password|passwd|secret|cookie|set-cookie)(["']?\s*[:=]\s*["']?|\s+)(?:(?:bearer|basic)\s+)?[^\s"',;&]+`)
func (c Config) redactor() redactor {
var r redactor
for _, a := range c.Accounts {
r.hosts = append(r.hosts, a.hosts...)
r.users = append(r.users, a.users...)
}
// Longest first, so a host's port form is replaced before its bare name.
sort.Slice(r.hosts, func(i, j int) bool { return len(r.hosts[i]) > len(r.hosts[j]) })
sort.Slice(r.users, func(i, j int) bool { return len(r.users[i]) > len(r.users[j]) })
return r
}
func (r redactor) clean(s string) string {
s = credential.ReplaceAllString(s, "${1}${2}<hidden>")
for _, u := range r.users {
s = strings.ReplaceAll(s, u, "<account>")
}
for _, h := range r.hosts {
s = strings.ReplaceAll(s, h, "<server>")
}
return s
}
// Run is one folder's last sync run, from its sync-run log.
type Run struct {
Started string `json:"started,omitempty"`
Finished string `json:"finished,omitempty"`
// State is "finished", "running", or "never" when the log has no run.
State string `json:"state"`
Changes int `json:"items"`
Errors int `json:"errors"`
Problems []Problem `json:"problems,omitempty"`
Log string `json:"log"`
}
// Problem is one item of a run that ended with an error.
type Problem struct {
File string `json:"file"`
Error string `json:"error"`
HTTP string `json:"http,omitempty"`
}
const mostProblems = 10
// syncLogFor finds the sync-run log whose first line is the folder's local path; the newest wins.
func (m *Machine) syncLogFor(local string) string {
want := strings.TrimSuffix(local, "/")
best, bestAt := "", time.Time{}
for _, d := range syncLogDirs {
files, _ := filepath.Glob(m.home(d, "*_sync.log"))
for _, f := range files {
h, err := os.Open(f)
if err != nil {
continue
}
first, _ := bufio.NewReader(io.LimitReader(h, 4096)).ReadString('\n')
h.Close()
if strings.TrimSuffix(strings.TrimSpace(first), "/") != want {
continue
}
if st, err := os.Stat(f); err == nil && st.ModTime().After(bestAt) {
best, bestAt = f, st.ModTime()
}
}
}
return best
}
// lastRun reads the end of a sync-run log: the last run's markers and its items.
func (m *Machine) lastRun(path string, r redactor) (*Run, error) {
lines, err := tailLines(path, 4000)
if err != nil {
return nil, err
}
run := &Run{State: "never", Log: m.tilde(strings.TrimPrefix(path, m.Root))}
start := -1
for i := len(lines) - 1; i >= 0; i-- {
if strings.HasPrefix(lines[i], "#=#=#=# Syncrun started ") {
start = i
break
}
}
if start < 0 {
return run, nil
}
run.Started = marker(lines[start], "#=#=#=# Syncrun started ")
run.State = "running"
for _, l := range lines[start+1:] {
switch {
case strings.HasPrefix(l, "#=#=#=# Syncrun finished "):
run.Finished = marker(l, "#=#=#=# Syncrun finished ")
run.State = "finished"
case strings.HasPrefix(l, "#"):
default:
f := strings.Split(l, "|")
if len(f) < 12 {
continue
}
run.Changes++
if e := strings.TrimSpace(f[10]); e != "" {
run.Errors++
if len(run.Problems) < mostProblems {
run.Problems = append(run.Problems, Problem{File: r.clean(f[2]), Error: r.clean(e), HTTP: strings.TrimSpace(f[11])})
}
}
}
}
return run, nil
}
func marker(line, prefix string) string {
f := strings.Fields(strings.TrimPrefix(line, prefix))
if len(f) == 0 {
return ""
}
return f[0]
}
// tailLines answers the last n lines of a file, plain or gzipped, read 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()
var r io.Reader = h
if strings.HasSuffix(path, ".gz") {
z, err := gzip.NewReader(h)
if err != nil {
return nil, fmt.Errorf("%s: %w", filepath.Base(path), err)
}
defer z.Close()
r = z
} else if st, err := h.Stat(); err == nil && st.Size() > MostRead {
// A plain file is read from its end.
if _, err := h.Seek(st.Size()-MostRead, io.SeekStart); err != nil {
return nil, err
}
}
raw, err := io.ReadAll(io.LimitReader(r, MostRead))
if err != nil {
return nil, err
}
all := strings.Split(strings.TrimRight(string(raw), "\n"), "\n")
if len(all) == 1 && all[0] == "" {
all = nil
}
if len(all) > n {
all = all[len(all)-n:]
}
return all, nil
}
// clientLogs are the client's log files, newest first.
func (m *Machine) clientLogs() []string {
entries, err := os.ReadDir(m.home(logDir))
if err != nil {
return nil
}
type file struct {
path string
at time.Time
}
var files []file
for _, e := range entries {
// The client's own log, not its permanent-delete records beside it.
if e.IsDir() || !strings.Contains(e.Name(), "_nextcloud.log") {
continue
}
if info, err := e.Info(); err == nil {
files = append(files, file{m.home(logDir, e.Name()), info.ModTime()})
}
}
sort.Slice(files, func(i, j int) bool { return files[i].at.After(files[j].at) })
out := make([]string, len(files))
for i, f := range files {
out[i] = f.path
}
return out
}
// level is a client log line's level: "[ warning category file:line ]:" → warning.
func level(line string) string {
i := strings.Index(line, "[ ")
if i < 0 {
return ""
}
f := strings.Fields(line[i+2:])
if len(f) == 0 {
return ""
}
return f[0]
}
func isProblem(line string) bool {
switch level(line) {
case "warning", "critical", "fatal":
return true
}
return false
}
// LogAnswer is what nextcloud_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
// Log answers the last n lines of the client's log (or only its warnings and worse), or of the sync
// runs' log, the server's address and the account's ids hidden.
func (m *Machine) Log(source string, n int, problemsOnly bool) (LogAnswer, error) {
c, err := m.config()
if err != nil {
return LogAnswer{}, err
}
r := c.redactor()
a := LogAnswer{Source: source, Files: []string{}, Lines: []string{}}
var files []string
switch source {
case "client":
files = m.clientLogs()
if len(files) == 0 {
a.Note = "the client keeps no log files in ~/" + logDir + ": it writes them there only while its logging is switched on"
return a, nil
}
case "sync":
for _, acc := range c.Accounts {
for _, f := range acc.Folders {
if p := m.syncLogFor(f.LocalPath); p != "" {
files = append(files, p)
}
}
}
if len(files) == 0 {
a.Note = "no sync-run log found for any configured folder"
return a, nil
}
default:
return a, fmt.Errorf("source is client or sync, not %q", source)
}
// The newest file first; older ones until n lines are found, at most four files.
var got []string
for i, f := range files {
if i == 4 || len(got) >= n {
break
}
lines, err := tailLines(f, 1<<20)
if err != nil {
return a, err
}
if problemsOnly {
var keep []string
for _, l := range lines {
if isProblem(l) {
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(f, m.Root)))
if source == "sync" {
// Each folder's log is its own story: the newest folder's only, unless asked again.
break
}
}
size := 0
for i := len(got) - 1; i >= 0; i-- {
l := r.clean(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
}
// Status is what nextcloud_status answers.
type Status struct {
Installed string `json:"installed,omitempty"`
Running []Proc `json:"running"`
Config Config `json:"settings"`
Problems []string `json:"recent_client_problems"`
StartedBy Autostart `json:"started_by"`
}
// Status reads the client: whether it runs, its accounts and folders with their last sync, and the
// warnings and worse at the end of its log.
func (m *Machine) Status() (Status, error) {
c, err := m.config()
if err != nil {
return Status{}, err
}
r := c.redactor()
s := Status{Running: m.procs(clientComm), Config: c, Problems: []string{}, StartedBy: m.autostart(entryName)}
if s.Running == nil {
s.Running = []Proc{}
}
if v, err := m.installed(packageFor); err == nil {
s.Installed = v
}
for ai := range s.Config.Accounts {
for fi := range s.Config.Accounts[ai].Folders {
f := &s.Config.Accounts[ai].Folders[fi]
if p := m.syncLogFor(f.LocalPath); p != "" {
if run, err := m.lastRun(p, r); err == nil {
f.LastSync = run
}
}
}
}
// The two newest files: the log rotates every two hours, and may just have.
logs := m.clientLogs()
if len(logs) > 2 {
logs = logs[:2]
}
for i := len(logs) - 1; i >= 0; i-- {
if lines, err := tailLines(logs[i], 1<<20); err == nil {
for _, l := range lines {
if isProblem(l) {
s.Problems = append(s.Problems, r.clean(l))
}
}
}
}
if len(s.Problems) > 10 {
s.Problems = s.Problems[len(s.Problems)-10:]
}
return s, nil
}
// RestartAnswer is what nextcloud_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 running client and starts it again in the operator's session, under the account's
// service manager, as its autostart entry does (--background: to the tray, no window).
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(6*time.Second, clientComm)
if err := m.detach(s, restartAs, clientBin, "--background"); err != nil {
return a, err
}
a.Running = m.waitFor(clientComm, 4*time.Second)
if len(a.Running) == 0 {
return a, fmt.Errorf("the client was started as %s but no %s process appeared within 4 s: "+
"see `journalctl --user -u %s`", a.Unit, clientComm, a.Unit)
}
return a, nil
}
// CheckAnswer is what nextcloud_check answers.
type CheckAnswer struct {
OK bool `json:"ok"`
Findings []Finding `json:"findings"`
Starts []string `json:"starts"`
}
// Check verifies the module's promises: the package, one start (the client's own autostart entry,
// which the session's dex runs), the client running once in a session, and its folders present.
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")
}
c, err := m.config()
if err != nil {
return a, err
}
entry := m.autostart(entryName)
if entry.Starts {
a.Starts = append(a.Starts, "XDG autostart: "+entry.From)
if !strings.Contains(entry.Exec, clientComm) {
add("the autostart entry runs "+entry.Exec+", not the client", "untick and tick again 'Launch on system startup' in the client's settings")
}
} else {
add("the client does not start with the session ("+entry.Because+")",
"tick 'Launch on system startup' in the client's General settings: the client writes its own entry")
}
if c.LaunchAtStartup != nil && !*c.LaunchAtStartup && entry.Starts {
add("the client's setting says not to launch at startup, but its autostart entry is there", "tick and untick the setting, or remove ~/.config/autostart/"+entryName)
}
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(clientComm) {
a.Starts = append(a.Starts, "window manager: "+l)
add("a second start: "+l, "remove the line; the autostart entry is the client's one start")
}
if o := m.cmd(0, m.bus(), "systemctl", "--user", "is-enabled", userUnit); o.Err == nil && strings.TrimSpace(o.Stdout) == "enabled" {
a.Starts = append(a.Starts, "user unit: "+userUnit)
add("a second start: the packaged user unit "+userUnit+" is enabled", "systemctl --user disable "+userUnit)
}
running := m.procs(clientComm)
if _, err := m.session(); err == nil {
switch {
case len(running) == 0:
add("no client runs in the desktop session", "nextcloud_restart")
case len(running) > 1:
add(fmt.Sprintf("%d clients run", len(running)), "nextcloud_restart ends them all and starts one")
}
}
if !c.Found {
add("the client has no settings yet (~/"+configFile+")", "open the client and add the account: its configuration is the operator's")
} else if len(c.Accounts) == 0 {
add("the client has no account", "add the account in the client")
}
for _, acc := range c.Accounts {
for _, f := range acc.Folders {
if !exists(filepath.Join(m.Root, f.LocalPath)) {
add("the sync folder "+m.tilde(f.LocalPath)+" of "+acc.Name+" does not exist", "recreate it, or remove the folder from the client")
} else if f.Journal != "" && !f.JournalFound {
add("the sync folder "+m.tilde(f.LocalPath)+" has no sync journal yet", "it is written at the first sync")
}
if f.Paused {
add("the sync folder "+m.tilde(f.LocalPath)+" is paused", "resume it in the client")
}
}
}
a.OK = len(a.Findings) == 0
return a, nil
}
@@ -0,0 +1,269 @@
package main
import (
"bytes"
"compress/gzip"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// A client's settings as the client writes them, with a certificate, a server and user ids that no
// answer may carry.
const cfg = `[General]
clientVersion=34.0.4daily
launchOnSystemStartup=true
[Accounts]
0\Folders\1\journalPath=.sync_abc.db
0\Folders\1\localPath=/home/operator/Nextcloud/
0\Folders\1\paused=false
0\Folders\1\targetPath=/
0\Folders\1\virtualFilesMode=off
0\Folders\2\localPath=/home/operator/Photos/
0\Folders\2\paused=true
0\Folders\2\targetPath=/Photos
0\General\CaCertificates="@ByteArray(-----BEGIN CERTIFICATE-----\nMIIE6zCC\n-----END CERTIFICATE-----)"
0\authType=webflow
0\dav_user=kc-1234-uid
0\displayName=The Operator
0\url=https://cloud.example.test:8443
0\user=kc-1234-uid@cloud.example.test
0\webflow_user=kc-1234-uid
version=13
`
const syncLog = `/home/operator/Nextcloud/
# timestamp | duration | file | instruction | dir | modtime | etag | size | fileId | status | errorString | http result code | other size | other modtime | X-Request-ID
#=#=#=# Syncrun started 2026-10-05T07:27:35Z
||old.md|2|2|1|e|1|i|13||0|1|1||
#=#=#=# Syncrun finished 2026-10-05T07:27:35Z (last step: 16 msec, total: 170 msec)
#=#=#=# Syncrun started 2026-10-05T09:27:40Z
#=#=#=#=# Propagation starts 2026-10-05T09:27:40Z (last step: 131 msec, total: 131 msec)
13:58:53||Notes/a.md|8|1|1|e|1|i|13||201|0|0|r|
13:58:54||Notes/b.md|8|1|1|e|1|i|13|"/Notes/b.md" is locked|423|0|0|r|
13:58:55||kc-1234-uid/c.md|8|1|1|e|1|i|13|File has changed since discovery|200|0|0|r|
#=#=#=# Syncrun finished 2026-10-05T09:27:40Z (last step: 17 msec, total: 148 msec)
`
func clientLine(level, text string) string {
return "2026-10-05 11:27:41:151 [ " + level + " nextcloud.gui.folder /src/folder.cpp:1 ]:\t" + text
}
func newClient(t *testing.T) *fake {
f := newFake(t)
f.write(testHome+"/"+configFile, cfg)
f.write(testHome+"/Nextcloud/.sync_abc.db", "")
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", syncLog)
// An older log of the same folder, in the old place: the newer one wins.
f.write(testHome+"/.config/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2023-11-17T00:00:00Z\n")
old := filepath.Join(f.Root, testHome, ".config/Nextcloud/Nextcloud_sync.log")
_ = os.Chtimes(old, time.Unix(1_700_000_000, 0), time.Unix(1_700_000_000, 0))
var gz bytes.Buffer
z := gzip.NewWriter(&gz)
_, _ = z.Write([]byte(clientLine("info", "older") + "\n" + clientLine("warning", "Network error on https://cloud.example.test:8443/x Authorization: Bearer abc.def") + "\n"))
_ = z.Close()
f.write(testHome+"/"+logDir+"/20261005_0927_nextcloud.log.0.gz", gz.String())
f.write(testHome+"/"+logDir+"/20261005_1127_permanent_delete.log.0", "not the client's log\n")
f.write(testHome+"/"+logDir+"/20261005_1127_nextcloud.log.0",
clientLine("info", "Sync finished for folder of account [kc-1234-uid@cloud.example.test]")+"\n"+clientLine("critical", "Could not read journal")+"\n")
gzPath := filepath.Join(f.Root, testHome, logDir, "20261005_0927_nextcloud.log.0.gz")
_ = os.Chtimes(gzPath, time.Now().Add(-time.Hour), time.Now().Add(-time.Hour))
f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nName=Nextcloud\nExec=\"/usr/bin/nextcloud\" --background\nX-GNOME-Autostart-enabled=true\n")
f.answer = func(name string, args []string) Output {
switch {
case name == "pacman":
return Output{Stdout: "nextcloud-client 2:34.0.4-1\n"}
case name == "systemctl" && len(args) > 1 && args[1] == "is-enabled":
return Output{Stdout: "disabled\n", Code: 1}
}
return Output{}
}
return f
}
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{"example.test", "kc-1234", "CERTIFICATE", "abc.def", "/home/operator"} {
if strings.Contains(s, never) {
t.Errorf("the answer carries %q: %s", never, s)
}
}
return s
}
func TestStatusNamesAccountsAndFoldersWithTheirLastSyncAndNothingSecret(t *testing.T) {
f := newClient(t)
f.proc(3865, 1000, clientComm, []string{"/usr/bin/nextcloud", "--background"}, "session-c1.scope")
s, err := f.Status()
if err != nil {
t.Fatal(err)
}
noSecrets(t, s)
if s.Installed != "2:34.0.4-1" || len(s.Running) != 1 || s.Running[0].StartedIn != "session-c1.scope" {
t.Fatalf("%+v", s)
}
if !s.StartedBy.Starts || s.StartedBy.From != "~/.config/autostart/Nextcloud.desktop" {
t.Fatalf("%+v", s.StartedBy)
}
if s.Config.ClientVersion != "34.0.4daily" || s.Config.LaunchAtStartup == nil || !*s.Config.LaunchAtStartup {
t.Fatalf("%+v", s.Config)
}
if len(s.Config.Accounts) != 1 {
t.Fatalf("%+v", s.Config.Accounts)
}
a := s.Config.Accounts[0]
if a.Name != "The Operator" || a.Auth != "webflow" || len(a.Folders) != 2 {
t.Fatalf("%+v", a)
}
main, photos := a.Folders[0], a.Folders[1]
if main.Local != "~/Nextcloud/" || main.RemotePath != "/" || main.Paused || !main.JournalFound || main.VirtualFiles != "off" {
t.Fatalf("%+v", main)
}
run := main.LastSync
if run == nil || run.State != "finished" || run.Started != "2026-10-05T09:27:40Z" || run.Finished != "2026-10-05T09:27:40Z" ||
run.Changes != 3 || run.Errors != 2 || run.Log != "~/.local/share/Nextcloud/Nextcloud_sync.log" {
t.Fatalf("%+v", run)
}
if run.Problems[0].File != "Notes/b.md" || run.Problems[0].HTTP != "423" || run.Problems[1].File != "<account>/c.md" {
t.Fatalf("%+v", run.Problems)
}
if !photos.Paused || photos.RemotePath != "/Photos" || photos.LastSync != nil {
t.Fatalf("%+v", photos)
}
// Warnings and worse of the two newest client logs, oldest first, hidden.
if len(s.Problems) != 2 || !strings.Contains(s.Problems[0], "<server>/x Authorization: <hidden>") ||
!strings.Contains(s.Problems[1], "Could not read journal") {
t.Fatalf("%q", s.Problems)
}
}
func TestARunWithoutItsEndIsRunningAndALogWithoutRunsIsNever(t *testing.T) {
f := newClient(t)
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n#=#=#=# Syncrun started 2026-10-05T10:00:00Z\n||x|1|1|1|e|1|i|13||200|0|0||\n")
s, _ := f.Status()
if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "running" || r.Finished != "" || r.Changes != 1 {
t.Fatalf("%+v", r)
}
f.write(testHome+"/.local/share/Nextcloud/Nextcloud_sync.log", "/home/operator/Nextcloud/\n")
s, _ = f.Status()
if r := s.Config.Accounts[0].Folders[0].LastSync; r.State != "never" {
t.Fatalf("%+v", r)
}
}
func TestNoSettingsIsNoAccountNotAnError(t *testing.T) {
f := newFake(t)
f.answer = func(string, []string) Output { return Output{Code: 1} }
s, err := f.Status()
if err != nil || s.Config.Found || len(s.Config.Accounts) != 0 || s.Installed != "" {
t.Fatalf("%+v %v", s, err)
}
}
func TestTheLogIsBoundedHiddenAndReadAcrossRotations(t *testing.T) {
f := newClient(t)
l, err := f.Log("client", 3, false)
if err != nil {
t.Fatal(err)
}
noSecrets(t, l)
if len(l.Lines) != 3 || !strings.Contains(l.Lines[0], "Authorization: <hidden>") || !strings.Contains(l.Lines[1], "[<account>]") ||
len(l.Files) != 2 || !strings.HasSuffix(l.Files[0], "_1127_nextcloud.log.0") {
t.Fatalf("%+v", l)
}
l, _ = f.Log("client", 1, false)
if len(l.Lines) != 1 || len(l.Files) != 1 || !strings.Contains(l.Lines[0], "Could not read journal") {
t.Fatalf("%+v", l)
}
l, _ = f.Log("client", 50, true)
if len(l.Lines) != 2 {
t.Fatalf("%+v", l)
}
l, _ = f.Log("sync", 2, false)
noSecrets(t, l)
if len(l.Lines) != 2 || !strings.HasPrefix(l.Lines[1], "#=#=#=# Syncrun finished") || l.Files[0] != "~/.local/share/Nextcloud/Nextcloud_sync.log" {
t.Fatalf("%+v", l)
}
if _, err := f.Log("server", 2, false); err == nil {
t.Fatal("an unknown source is refused")
}
empty := newFake(t)
if l, err := empty.Log("client", 5, false); err != nil || l.Note == "" || l.Lines == nil {
t.Fatalf("%+v %v", l, err)
}
}
func TestRestartEndsTheClientAndStartsOneInTheSession(t *testing.T) {
f := newClient(t)
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "no graphical session") {
t.Fatalf("without a desktop: %v", err)
}
f.desktopSession()
f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-4.scope")
f.onStart = func(argv []string) {
f.proc(9000, 1000, clientComm, argv, "app.slice/"+restartAs+".service")
}
a, err := f.Restart()
if err != nil {
t.Fatal(err)
}
if len(a.Ended) != 1 || a.Ended[0] != 3865 || len(a.Running) != 1 || a.Running[0].PID != 9000 ||
a.Running[0].StartedIn != restartAs+".service" || a.Running[0].Command != "/usr/bin/nextcloud --background" {
t.Fatalf("%+v", a)
}
f.onStart = nil
if _, err := f.Restart(); err == nil || !strings.Contains(err.Error(), "journalctl --user -u "+restartAs) {
t.Fatalf("a start that shows no process: %v", err)
}
}
func TestCheckPassesOneStartAndNamesEveryOther(t *testing.T) {
f := newClient(t)
f.desktopSession()
f.write(testHome+"/Photos/.keep", "")
f.proc(3865, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope")
c, err := f.Check()
if err != nil {
t.Fatal(err)
}
// The one finding is the paused folder.
if c.OK || len(c.Findings) != 1 || !strings.Contains(c.Findings[0].What, "~/Photos/ is paused") || len(c.Starts) != 1 {
t.Fatalf("%+v", c)
}
noSecrets(t, c)
f.write(testHome+"/.config/i3/config", "exec --no-startup-id nextcloud\n")
f.answer = func(name string, args []string) Output {
switch name {
case "pacman":
return Output{Code: 1, Stderr: "error: package 'nextcloud-client' was not found"}
case "systemctl":
return Output{Stdout: "enabled\n"}
case "dex":
return Output{Code: 127, Err: ErrNotInstalled}
}
return Output{}
}
f.proc(3866, 1000, clientComm, []string{"nextcloud"}, "session-c1.scope")
f.write(testHome+"/.config/autostart/Nextcloud.desktop", "[Desktop Entry]\nExec=nextcloud\nHidden=true\n")
c, _ = f.Check()
all := noSecrets(t, c)
for _, want := range []string{"not installed", "does not start with the session (Hidden=true)", "dex", "a second start: ~/.config/i3/config:1",
"packaged user unit", "2 clients run"} {
if !strings.Contains(all, want) {
t.Errorf("no finding %q in %s", want, all)
}
}
if len(c.Starts) != 2 {
t.Fatalf("%q", c.Starts)
}
}
+5
View File
@@ -0,0 +1,5 @@
module nextcloud-client
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.7
+2
View File
@@ -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=
+38
View File
@@ -0,0 +1,38 @@
{
"module": "nextcloud-client",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"tools": [
"nextcloud_status",
"nextcloud_log",
"nextcloud_restart",
"nextcloud_check"
],
"resources": [
{
"id": "package",
"type": "package",
"package": "nextcloud-client"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/nextcloud-client-tools",
"binary": "nextcloud-client-tools",
"loads": [
"nextcloud-client-tools"
]
}
]
}
}