screen-lock: the lock screen as a module, claiming node-lock-screen and serving lock (hq ADR 0208)

The distribution's i3lock behind a locker that releases xss-lock's sleep lock
once it is up; timeouts and xss-lock from the session's xinitrc slot, ending
with the session; i3lock-color and xscreensaver declared absent; Go tools lock,
idle, inhibit and locked.
This commit is contained in:
jochen
2026-10-04 13:10:58 +02:00
parent 1fcc0ffbe2
commit 04f3f66f4b
13 changed files with 1749 additions and 0 deletions
+73
View File
@@ -0,0 +1,73 @@
# screen-lock
The lock screen, idle timeouts and display power as one module (novox/hq ADR 0208, research
026/04).
- Installs `xss-lock` and the distribution's `i3lock`. Claims the mesh's `node-lock-screen` seat and
serves its verb `lock`. Requires `x11-display` on its own machine.
- **Declares absent** (ADR 0180), as replaced and not coming back:
- `i3lock-color`, the colour build from the user repository;
- `xscreensaver`, a second screensaver that was installed and deliberately never started.
- Places the locker `~/.local/bin/screen-lock`. The lock key (`$mod+Delete`) is the `i3` module's.
It asks logind to lock and names no locker, so it does not depend on which module holds the seat.
- At session start, through the `xinitrc` slot `normal`, it:
- sets the timeouts: lock after 30 minutes idle, displays to standby and suspend at 30 minutes and
off at 60;
- starts `xss-lock --transfer-sleep-lock`, which runs the locker on idle, before suspend and on
logind's Lock, so the lock key, a closed lid and a suspend all lead to one locker.
xss-lock stays out of the units on purpose. It must find its own login session, and a user unit
runs in the service manager's session instead, where xss-lock silently finds none.
## Tools
| tool | does |
|---|---|
| `node-lock-screen.lock` | lock now, through logind, so the session's one locker answers; answers since when |
| `screen_lock_idle` | the idle and display power timeouts in force; change any of them for this session |
| `screen_lock_inhibit` | keep the screen on and unlocked for N minutes, then restore; 0 ends it early |
| `screen_lock_locked` | locked or not and since when, whether xss-lock runs, whether an inhibition holds |
An inhibition runs under the account's service manager (`screen-lock-inhibit.service`). Stopping it
restores the timeouts at once. It holds off the idle lock and display power only: a lock asked for by
hand, by the lid or before suspend still locks.
## Decided: the distribution's i3lock now
The colour build lives in the user repository, and the colours are the only difference. The module
uses the official `i3lock` (black, failed attempts shown, an empty Enter ignored). The colour build
can come back as a pinned archive of this module (ADR 0205). That is a follow-up, and only the
locker's options change with it.
## What it improves on what was found
- **The machine no longer waits for the unlock to suspend.** The found wrapper started i3lock with
xss-lock's sleep lock inherited, so a suspend was held until logind's delay ran out. The new locker
follows xss-lock's own pattern: the lock is released as soon as i3lock is up.
- **One locker.** A second lock while locked does nothing. The power menu locks through logind
instead of starting its own i3lock.
- **The respawn loop ends with the session.** The found loop kept retrying every two seconds after
logout.
- **The timeouts are set once, in one place.** On the laptop, measured on 2026-10-04, the screensaver
timeout in force was 600 s, not the 1800 s the start script asked for.
## What it leaves as found
- `~/.xscreensaver`, xscreensaver's configuration file. Remove it once the package is gone.
- `~/scripts/my-i3lock`, the predecessor's wrapper, which only the colour build understands.
## Migration (ADR 0182)
1. **Before the first push,** remove the colour build by hand: `sudo pacman -R i3lock-color`.
`pacman --noconfirm` will not replace a conflicting package. If the host installs `i3lock` before
it removes `i3lock-color`, the first push fails on the conflict.
2. Once the `xorg` module writes the session's start, delete from your own part of `~/.xinitrc`:
- the `xset s` and `xset dpms` lines;
- the `while true; do xss-lock … my-i3lock; …; done &` loop.
3. Delete `~/.xscreensaver` and `~/scripts/my-i3lock`.
## Blockers
- `node-lock-screen`, `x11-display` and the `xinitrc` slot are ADR 0208's. Until the controller knows
them, `mctl` reads them as unknown.
- `xset` comes with the display server's module (`xorg`).
@@ -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,337 @@
package main
import (
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
)
// The declared timeouts, which the session start sets (module.json's xinitrc contribution) and an
// inhibition returns to when it cannot read what was in force.
const (
declaredLock = 1800
declaredStandby = 1800
declaredSuspend = 1800
declaredOff = 3600
mostInhibit = 600
inhibitUnit = "screen-lock-inhibit"
lockerUnit = "screen-lock"
)
// clockTicks is the kernel's USER_HZ, which /proc/<pid>/stat counts a start time in: 100 on every
// architecture Arch Linux builds for.
const clockTicks = 100
func locker() string { return filepath.Join(operatorHome(), ".local", "bin", "screen-lock") }
// LockState is what lock and locked answer.
type LockState struct {
Locked bool `json:"locked"`
// Since is when the locker started, when it runs.
Since string `json:"since,omitempty"`
PIDs []int `json:"pids"`
LockedHint *bool `json:"locked_hint,omitempty"`
Watcher bool `json:"watcher_running"`
Inhibited bool `json:"inhibited"`
Via string `json:"via,omitempty"`
}
// Lock locks through logind when the watcher runs, else runs the locker itself.
func Lock() (LockState, error) {
s, err := findSession()
if err != nil {
return LockState{}, err
}
via := ""
switch {
case len(processesOf("i3lock")) > 0:
via = "already locked"
case len(processesOf("xss-lock")) > 0 && s.SessionID != "":
r, err := s.run(10*time.Second, "", "loginctl", "lock-session", s.SessionID)
if err != nil {
return LockState{}, err
}
if r.Code != 0 {
return LockState{}, fmt.Errorf("loginctl lock-session %s: %s", s.SessionID, strings.TrimSpace(r.Stderr))
}
via = "logind, answered by xss-lock"
default:
// No watcher: the session start's loop is not running (a session begun before this module
// was assigned). The locker is run directly, under the account's service manager.
if err := s.detach(lockerUnit, locker()); err != nil {
return LockState{}, err
}
via = "the locker directly: xss-lock is not running in this session"
}
deadline := time.Now().Add(3 * time.Second)
for len(processesOf("i3lock")) == 0 && time.Now().Before(deadline) {
time.Sleep(100 * time.Millisecond)
}
state := lockState(s)
state.Via = via
if !state.Locked {
return state, errors.New("asked to lock, and no locker is running 3s later")
}
return state, nil
}
// Locked answers the lock state without changing it.
func Locked() (LockState, error) {
s := findEnvironment()
return lockState(s), nil
}
func lockState(s Session) LockState {
pids := processesOf("i3lock")
st := LockState{Locked: len(pids) > 0, PIDs: pids, Watcher: len(processesOf("xss-lock")) > 0}
if st.PIDs == nil {
st.PIDs = []int{}
}
if len(pids) > 0 {
if at, ok := startTime(pids[0]); ok {
st.Since = at.Format(time.RFC3339)
}
}
if s.SessionID != "" {
if r, err := s.run(5*time.Second, "", "loginctl", "show-session", s.SessionID, "-p", "LockedHint", "--value"); err == nil && r.Code == 0 {
hint := strings.TrimSpace(r.Stdout) == "yes"
st.LockedHint = &hint
}
}
if s.RuntimeDir != "" {
if r, err := s.run(5*time.Second, "", "systemctl", "--user", "is-active", inhibitUnit+".service"); err == nil {
st.Inhibited = strings.TrimSpace(r.Stdout) == "active"
}
}
return st
}
// startTime is when a process started, from its start in clock ticks after boot and the boot time.
func startTime(pid int) (time.Time, bool) {
stat, err := os.ReadFile(filepath.Join(procRoot, strconv.Itoa(pid), "stat"))
if err != nil {
return time.Time{}, false
}
// The command name is in parentheses and may hold spaces; the fields after it are fixed.
end := strings.LastIndexByte(string(stat), ')')
if end < 0 {
return time.Time{}, false
}
fields := strings.Fields(string(stat[end+1:]))
// starttime is field 22 of the whole line; after "pid (comm)" it is the 20th.
if len(fields) < 20 {
return time.Time{}, false
}
ticks, err := strconv.ParseInt(fields[19], 10, 64)
if err != nil {
return time.Time{}, false
}
boot, ok := bootTime()
if !ok {
return time.Time{}, false
}
return boot.Add(time.Duration(ticks) * time.Second / clockTicks), true
}
func bootTime() (time.Time, bool) {
raw, err := os.ReadFile(filepath.Join(procRoot, "stat"))
if err != nil {
return time.Time{}, false
}
for _, line := range strings.Split(string(raw), "\n") {
if v, ok := strings.CutPrefix(line, "btime "); ok {
n, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64)
if err == nil {
return time.Unix(n, 0), true
}
}
}
return time.Time{}, false
}
// IdleState is the screen's idle timeouts in force.
type IdleState struct {
LockAfterSeconds int `json:"lock_after_seconds"`
CycleSeconds int `json:"cycle_seconds"`
DPMSEnabled bool `json:"dpms_enabled"`
StandbySeconds int `json:"standby_seconds"`
SuspendSeconds int `json:"suspend_seconds"`
OffSeconds int `json:"off_seconds"`
MonitorOn bool `json:"monitor_on"`
Declared string `json:"declared"`
Note string `json:"note,omitempty"`
}
var (
screensaverLine = regexp.MustCompile(`timeout:\s+(\d+)\s+cycle:\s+(\d+)`)
dpmsLine = regexp.MustCompile(`Standby:\s+(\d+)\s+Suspend:\s+(\d+)\s+Off:\s+(\d+)`)
)
func parseXsetQ(out string) (IdleState, error) {
var st IdleState
m := screensaverLine.FindStringSubmatch(out)
if m == nil {
return st, errors.New("xset q shows no screensaver timeout")
}
st.LockAfterSeconds, _ = strconv.Atoi(m[1])
st.CycleSeconds, _ = strconv.Atoi(m[2])
if d := dpmsLine.FindStringSubmatch(out); d != nil {
st.StandbySeconds, _ = strconv.Atoi(d[1])
st.SuspendSeconds, _ = strconv.Atoi(d[2])
st.OffSeconds, _ = strconv.Atoi(d[3])
}
st.DPMSEnabled = strings.Contains(out, "DPMS is Enabled")
st.MonitorOn = strings.Contains(out, "Monitor is On")
st.Declared = fmt.Sprintf("lock after %ds; DPMS %d/%d/%d", declaredLock, declaredStandby, declaredSuspend, declaredOff)
return st, nil
}
// IdleChange is what screen_lock_idle was asked to change; nil fields stay.
type IdleChange struct {
LockAfter, Standby, Suspend, Off *int
}
func idleChangeOf(args map[string]any) (IdleChange, error) {
var c IdleChange
for key, into := range map[string]**int{
"lock_after_seconds": &c.LockAfter, "standby_seconds": &c.Standby,
"suspend_seconds": &c.Suspend, "off_seconds": &c.Off,
} {
if _, given := args[key]; !given {
continue
}
n, err := whole(args, key, 0, 0, 24*3600)
if err != nil {
return c, err
}
*into = &n
}
return c, nil
}
func (c IdleChange) empty() bool {
return c.LockAfter == nil && c.Standby == nil && c.Suspend == nil && c.Off == nil
}
// Idle reads the timeouts, and changes those asked for.
func Idle(change IdleChange) (IdleState, error) {
s, err := findSession()
if err != nil {
return IdleState{}, err
}
before, err := xsetQ(s)
if err != nil {
return IdleState{}, err
}
if change.empty() {
return before, nil
}
if change.LockAfter != nil {
cycle := before.CycleSeconds
if *change.LockAfter > 0 && cycle == 0 {
cycle = *change.LockAfter
}
if err := xset(s, "s", strconv.Itoa(*change.LockAfter), strconv.Itoa(cycle)); err != nil {
return IdleState{}, err
}
}
if change.Standby != nil || change.Suspend != nil || change.Off != nil {
pick := func(c *int, was int) string {
if c != nil {
return strconv.Itoa(*c)
}
return strconv.Itoa(was)
}
if err := xset(s, "dpms", pick(change.Standby, before.StandbySeconds), pick(change.Suspend, before.SuspendSeconds),
pick(change.Off, before.OffSeconds)); err != nil {
return IdleState{}, err
}
}
after, err := xsetQ(s)
if err != nil {
return IdleState{}, err
}
after.Note = "changed for this session only; the declared timeouts return at the next login"
return after, nil
}
func xsetQ(s Session) (IdleState, error) {
r, err := s.run(5*time.Second, "", "xset", "q")
if err != nil {
return IdleState{}, err
}
if r.Code != 0 {
return IdleState{}, fmt.Errorf("xset q: %s", strings.TrimSpace(r.Stderr))
}
return parseXsetQ(r.Stdout)
}
func xset(s Session, args ...string) error {
r, err := s.run(5*time.Second, "", "xset", args...)
if err != nil {
return err
}
if r.Code != 0 {
return fmt.Errorf("xset %s: %s", strings.Join(args, " "), strings.TrimSpace(r.Stderr))
}
return nil
}
// InhibitResult is what screen_lock_inhibit answers.
type InhibitResult struct {
Inhibited bool `json:"inhibited"`
Until string `json:"until,omitempty"`
Restores string `json:"restores,omitempty"`
}
// Inhibit keeps the screen on for minutes, then restores the timeouts that were in force; 0 ends an
// inhibition now. The waiting runs under the account's service manager, so it outlives this call,
// and stopping it restores at once.
func Inhibit(minutes int) (InhibitResult, error) {
s, err := findSession()
if err != nil {
return InhibitResult{}, err
}
if minutes == 0 {
r, err := s.run(10*time.Second, "", "systemctl", "--user", "stop", inhibitUnit+".service")
if err != nil {
return InhibitResult{}, err
}
if r.Code != 0 {
return InhibitResult{}, fmt.Errorf("ending the inhibition: %s", strings.TrimSpace(r.Stderr))
}
return InhibitResult{Inhibited: false}, nil
}
// What to return to: what is in force now, unless an inhibition is already holding it at off.
was, err := xsetQ(s)
if err != nil {
return InhibitResult{}, err
}
if lockState(s).Inhibited || (was.LockAfterSeconds == 0 && !was.DPMSEnabled) {
was = IdleState{LockAfterSeconds: declaredLock, CycleSeconds: declaredLock, DPMSEnabled: true,
StandbySeconds: declaredStandby, SuspendSeconds: declaredSuspend, OffSeconds: declaredOff}
}
script := inhibitScript(minutes, was)
if err := s.detach(inhibitUnit, "/bin/sh", "-c", script); err != nil {
return InhibitResult{}, err
}
return InhibitResult{Inhibited: true, Until: time.Now().Add(time.Duration(minutes) * time.Minute).Format(time.RFC3339),
Restores: fmt.Sprintf("lock after %ds; DPMS %d/%d/%d", was.LockAfterSeconds, was.StandbySeconds, was.SuspendSeconds, was.OffSeconds)}, nil
}
func inhibitScript(minutes int, was IdleState) string {
dpms := "+dpms"
if !was.DPMSEnabled {
dpms = "-dpms"
}
return fmt.Sprintf("restore() { xset s %d %d; xset dpms %d %d %d; xset %s; }; "+
"trap 'restore; exit 0' TERM INT; xset s off -dpms; sleep %d & wait; restore",
was.LockAfterSeconds, was.CycleSeconds, was.StandbySeconds, was.SuspendSeconds, was.OffSeconds, dpms, minutes*60)
}
@@ -0,0 +1,189 @@
package main
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
const nobody = 4194400
// xset q as the laptop answered it on 2026-10-04 (keyboard lines shortened).
const xsetQOutput = `Keyboard Control:
auto repeat: on key click percent: 0 LED mask: 00000000
Screen Saver:
prefer blanking: yes allow exposures: yes
timeout: 600 cycle: 600
Colors:
default colormap: 0x20 BlackPixel: 0x0 WhitePixel: 0xffffff
DPMS (Display Power Management Signaling):
Standby: 1800 Suspend: 1800 Off: 3600
DPMS is Enabled
Monitor is On
`
func TestTheTimeoutsAreReadFromXset(t *testing.T) {
st, err := parseXsetQ(xsetQOutput)
if err != nil {
t.Fatal(err)
}
if st.LockAfterSeconds != 600 || st.CycleSeconds != 600 || !st.DPMSEnabled || st.StandbySeconds != 1800 ||
st.SuspendSeconds != 1800 || st.OffSeconds != 3600 || !st.MonitorOn {
t.Fatalf("%+v", st)
}
if _, err := parseXsetQ("nothing"); err == nil {
t.Fatal("an answer without a screensaver was accepted")
}
}
func TestAProcessStartsWhenItsStatAndTheBootTimeSay(t *testing.T) {
fakeMachine(t)
fakeProcess(t, nobody, "i3lock")
// A command name with a space and a parenthesis, which a naive split gets wrong.
stat := strconv.Itoa(nobody) + " (i3 lock) x) S 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 12345 0 0\n"
if err := os.WriteFile(filepath.Join(procRoot, strconv.Itoa(nobody), "stat"), []byte(stat), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(procRoot, "stat"), []byte("cpu 1 2 3\nbtime 1700000000\n"), 0o644); err != nil {
t.Fatal(err)
}
at, ok := startTime(nobody)
if !ok || !at.Equal(time.Unix(1700000000, 0).Add(123450*time.Millisecond)) {
t.Fatalf("%v %v", at, ok)
}
}
// lockingMachine is a session whose loginctl, asked to lock, starts a (fake) i3lock.
func lockingMachine(t *testing.T, watcher bool) string {
t.Helper()
fakeMachine(t)
fakeProcess(t, nobody, "i3", "DISPLAY=:1", "XDG_SESSION_ID=4")
if watcher {
fakeProcess(t, nobody+1, "xss-lock", "DISPLAY=:1")
}
if err := os.MkdirAll(filepath.Join(runUserDir, strconv.Itoa(os.Getuid())), 0o700); err != nil {
t.Fatal(err)
}
lockNow := `mkdir -p "$PROC/` + strconv.Itoa(nobody+2) + `" && echo i3lock > "$PROC/` + strconv.Itoa(nobody+2) + `/comm"`
bin := fakeBinaries(t, map[string]string{
"loginctl": `echo "loginctl $*" >> "$LOG"
case "$1" in lock-session) ` + lockNow + ` ;; show-session) echo yes ;; esac`,
"systemd-run": `echo "systemd-run $*" >> "$LOG"; ` + lockNow,
"systemctl": `echo "systemctl $*" >> "$LOG"; echo inactive`,
})
t.Setenv("LOG", filepath.Join(bin, "log"))
t.Setenv("PROC", procRoot)
return bin
}
func TestLockGoesThroughLogindSoTheOneLockerAnswers(t *testing.T) {
bin := lockingMachine(t, true)
st, err := Lock()
if err != nil {
t.Fatal(err)
}
if !st.Locked || !st.Watcher || st.LockedHint == nil || !*st.LockedHint || !strings.Contains(st.Via, "logind") {
t.Fatalf("%+v", st)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(log), "loginctl lock-session 4\n") || strings.Contains(string(log), "systemd-run") {
t.Fatalf("asked:\n%s", log)
}
again, err := Lock()
if err != nil || again.Via != "already locked" {
t.Fatalf("locking a locked screen: %+v, %v", again, err)
}
}
func TestWithoutTheWatcherTheLockerRunsUnderTheAccountsServiceManager(t *testing.T) {
bin := lockingMachine(t, false)
st, err := Lock()
if err != nil || !st.Locked || !strings.Contains(st.Via, "xss-lock is not running") {
t.Fatalf("%+v, %v", st, err)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(log), "--unit=screen-lock --setenv=DISPLAY=:1 --setenv=XDG_SESSION_ID=4 -- ") ||
!strings.Contains(string(log), "/.local/bin/screen-lock") {
t.Fatalf("asked:\n%s", log)
}
}
func TestLockedWithoutASessionIsAnAnswerNotAnError(t *testing.T) {
fakeMachine(t)
st, err := Locked()
if err != nil || st.Locked || st.Watcher || st.PIDs == nil {
t.Fatalf("%+v, %v", st, err)
}
if _, err := Lock(); !errors.Is(err, ErrNoSession) {
t.Fatalf("lock without a session: %v", err)
}
}
func TestIdleChangesOnlyWhatWasAskedAndSaysForHowLong(t *testing.T) {
fakeMachine(t)
fakeProcess(t, nobody, "i3", "DISPLAY=:1")
bin := fakeBinaries(t, map[string]string{"xset": `echo "xset $*" >> "$LOG"; [ "$1" = q ] && cat "$Q"; true`})
t.Setenv("LOG", filepath.Join(bin, "log"))
q := filepath.Join(bin, "q")
if err := os.WriteFile(q, []byte(xsetQOutput), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("Q", q)
if st, err := Idle(IdleChange{}); err != nil || st.Note != "" || st.LockAfterSeconds != 600 {
t.Fatalf("read: %+v, %v", st, err)
}
c, err := idleChangeOf(map[string]any{"lock_after_seconds": float64(900), "off_seconds": float64(7200)})
if err != nil {
t.Fatal(err)
}
st, err := Idle(c)
if err != nil || !strings.Contains(st.Note, "next login") {
t.Fatalf("%+v, %v", st, err)
}
log, _ := os.ReadFile(filepath.Join(bin, "log"))
if !strings.Contains(string(log), "xset s 900 600\n") || !strings.Contains(string(log), "xset dpms 1800 1800 7200\n") {
t.Fatalf("asked:\n%s", log)
}
if _, err := idleChangeOf(map[string]any{"off_seconds": float64(-1)}); err == nil {
t.Fatal("a negative timeout was accepted")
}
}
func TestAnInhibitionTurnsIdleOffAndRestoresWhatWasThereWhenItEndsOrIsStopped(t *testing.T) {
was := IdleState{LockAfterSeconds: 1800, CycleSeconds: 1800, DPMSEnabled: true, StandbySeconds: 1800, SuspendSeconds: 1800, OffSeconds: 3600}
script := inhibitScript(2, was)
if !strings.Contains(script, "xset s off -dpms; sleep 120 & wait; restore") ||
!strings.Contains(script, "restore() { xset s 1800 1800; xset dpms 1800 1800 3600; xset +dpms; }") ||
!strings.Contains(script, "trap 'restore; exit 0' TERM") {
t.Fatalf("%s", script)
}
// Run it for real with a fake xset, stopped early as systemctl stop would.
dir := t.TempDir()
bin := fakeBinaries(t, map[string]string{"xset": `echo "$*" >> "` + filepath.Join(dir, "log") + `"`})
_ = bin
cmd := exec.Command("/bin/sh", "-c", inhibitScript(1, was))
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
time.Sleep(300 * time.Millisecond)
_ = cmd.Process.Signal(os.Interrupt)
_ = cmd.Wait()
got, _ := os.ReadFile(filepath.Join(dir, "log"))
if string(got) != "s off -dpms\ns 1800 1800\ndpms 1800 1800 3600\n+dpms\n" {
t.Fatalf("the timeouts were not restored on stop:\n%s", got)
}
}
func TestTheDeclaredTimeoutsAreTheSessionStartsOwn(t *testing.T) {
m := readManifest(t)
code := m.Shell[0].Code
if !strings.Contains(code, "xset s "+strconv.Itoa(declaredLock)+" "+strconv.Itoa(declaredLock)+"\n") ||
!strings.Contains(code, "xset dpms "+strconv.Itoa(declaredStandby)+" "+strconv.Itoa(declaredSuspend)+" "+strconv.Itoa(declaredOff)+"\n") {
t.Fatalf("the tools' declared values and the session start's disagree:\n%s", code)
}
}
@@ -0,0 +1,78 @@
// screen-lock's Go tools bundle (novox/hq ADR 0188, ADR 0193, ADR 0208): its implementation of
// node-lock-screen's verb `lock`, and its own tools for the idle timeouts, served by the node's
// runtime as the operator account.
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)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "node-lock-screen.lock",
Description: "Lock the operator's session now. It goes through logind, so the one locker the " +
"session runs answers it, as the lock key and a closed lid do. Answers whether the screen is " +
"locked and since when.",
Run: func(map[string]any) (any, error) { return Lock() },
},
{
Name: "screen_lock_idle",
Description: "The screen's idle timeouts: after how long idle the session locks (the X screensaver) " +
"and when the displays go to standby, suspend and off (DPMS). Give any of them to change it for " +
"this session; the declared ones return at the next login.",
Input: map[string]any{
"lock_after_seconds": map[string]any{"type": "integer", "description": "idle time before the lock; 0 never"},
"standby_seconds": map[string]any{"type": "integer", "description": "DPMS standby; 0 never"},
"suspend_seconds": map[string]any{"type": "integer", "description": "DPMS suspend; 0 never"},
"off_seconds": map[string]any{"type": "integer", "description": "DPMS off; 0 never"},
},
Run: func(args map[string]any) (any, error) {
change, err := idleChangeOf(args)
if err != nil {
return nil, err
}
return Idle(change)
},
},
{
Name: "screen_lock_inhibit",
Description: "Keep the screen on and unlocked for a while — a presentation, a film, a long read: " +
"no idle lock and no display power-off for `minutes`, then the timeouts as they were. 0 ends an " +
"inhibition early. A lock asked for by hand, by the lid or before suspend still locks.",
Input: map[string]any{
"type": "object",
"properties": map[string]any{
"minutes": map[string]any{"type": "integer", "description": fmt.Sprintf("how long, 1-%d; 0 ends it now", mostInhibit)},
},
"required": []string{"minutes"},
},
Run: func(args map[string]any) (any, error) {
if _, given := args["minutes"]; !given {
return nil, fmt.Errorf("minutes is required")
}
minutes, err := whole(args, "minutes", 0, 0, mostInhibit)
if err != nil {
return nil, err
}
return Inhibit(minutes)
},
},
{
Name: "screen_lock_locked",
Description: "Is the operator's session locked now, and since when; whether the lock watcher " +
"(xss-lock) runs, and whether an inhibition is keeping the screen on.",
Run: func(map[string]any) (any, error) { return Locked() },
},
}
}
@@ -0,0 +1,175 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// The module's manifest, read the way the catalogue reads it, for the manifest tests. The same in
// every desktop module that carries it.
type manifest struct {
Module string `json:"module"`
Version string `json:"version"`
Capabilities []string `json:"capabilities"`
Requires []string `json:"requires"`
Claims []claim `json:"claims"`
Seats []any `json:"seats"`
Tools []string `json:"tools"`
Environment *environment `json:"environment"`
Shell []shellCode `json:"shell"`
Resources []map[string]any `json:"resources"`
Build struct {
Artifacts []map[string]any `json:"artifacts"`
} `json:"build"`
}
type claim struct {
Name string `json:"name"`
Scope string `json:"scope"`
Serves []string `json:"serves"`
}
type environment struct {
Variables map[string]string `json:"variables"`
Path []map[string]any `json:"path"`
}
type shellCode struct {
For string `json:"for"`
Slot string `json:"slot"`
Code string `json:"code"`
}
func readManifest(t *testing.T) manifest {
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
}
func (m manifest) resource(t *testing.T, id string) map[string]any {
t.Helper()
for _, r := range m.Resources {
if r["id"] == id {
return r
}
}
t.Fatalf("no resource %q", id)
return nil
}
func (m manifest) packages() (present, absent []string) {
for _, r := range m.Resources {
if r["type"] == "package" {
if r["absent"] == true {
absent = append(absent, r["package"].(string))
} else {
present = append(present, r["package"].(string))
}
}
}
return present, absent
}
// sameAsSource checks that a file resource's content is byte for byte the module's source file, so
// the readable file in the repository is what the machine gets.
func (m manifest) sameAsSource(t *testing.T, id, source string) {
t.Helper()
want, err := os.ReadFile(filepath.Join("..", "..", source))
if err != nil {
t.Fatal(err)
}
r := m.resource(t, id)
if r["type"] != "file" {
t.Fatalf("%s is a %v, not a file", id, r["type"])
}
if got, _ := r["content"].(string); got != string(want) {
t.Fatalf("resource %s's content is not %s: edit the source and copy it into module.json", id, source)
}
if r["owner"] != "${machine:account}" && !strings.HasPrefix(r["path"].(string), "/etc/") {
t.Fatalf("%s under the home is the account's", id)
}
}
// checkTheToolsAgree checks that the manifest lists the module's own tools exactly, that the bundle
// serves each seat verb the claims promise as <seat>.<verb>, and that the Go bundle is declared.
func checkTheToolsAgree(t *testing.T, m manifest) {
t.Helper()
own, seat := map[string]bool{}, map[string]bool{}
for _, tool := range tools() {
if strings.Contains(tool.Name, ".") {
seat[tool.Name] = true
} else {
own[tool.Name] = true
}
if strings.TrimSpace(tool.Description) == "" {
t.Errorf("%s has no description", tool.Name)
}
}
listed := map[string]bool{}
for _, name := range m.Tools {
listed[name] = true
if !own[name] {
t.Errorf("module.json lists %s, which the bundle does not serve", name)
}
}
for name := range own {
if !listed[name] {
t.Errorf("the bundle serves %s, which module.json does not list", name)
}
if !strings.HasPrefix(name, strings.ReplaceAll(m.Module, "-", "_")+"_") {
t.Errorf("%s is not prefixed with the module's name", name)
}
}
promised := map[string]bool{}
for _, c := range m.Claims {
for _, verb := range c.Serves {
promised[c.Name+"."+verb] = true
if !seat[c.Name+"."+verb] {
t.Errorf("the claim on %s promises %s, which the bundle does not serve", c.Name, verb)
}
}
}
for name := range seat {
if !promised[name] {
t.Errorf("the bundle serves %s, which no claim promises", name)
}
}
var bundle map[string]any
for _, a := range m.Build.Artifacts {
if a["kind"] == "bundle" {
bundle = a
}
}
if bundle == nil || bundle["language"] != "go" || bundle["system"] != "arch" ||
bundle["from"] != "cmd/"+m.Module+"-tools" || bundle["binary"] != m.Module+"-tools" {
t.Errorf("the Go tools bundle: %v", bundle)
}
}
// checkNoSecretsOrInstallationNames refuses what a catalogue manifest must never carry.
func checkNoSecretsOrInstallationNames(t *testing.T) {
t.Helper()
raw, err := os.ReadFile(filepath.Join("..", "..", "module.json"))
if err != nil {
t.Fatal(err)
}
s := strings.ToLower(string(raw))
for _, never := range []string{"/home/", "jochen", "g14", "shanks", "novox.be", "api_key", ".hal/", "greenclip daemon"} {
if strings.Contains(s, never) {
t.Errorf("module.json names %q", never)
}
}
}
@@ -0,0 +1,84 @@
package main
import (
"reflect"
"strings"
"testing"
)
// screen-lock's shape (novox/hq ADR 0208, research 026/04): it claims node-lock-screen serving lock,
// requires the X display on its own machine, installs the watcher and the distribution's locker,
// declares the colour build and xscreensaver absent, places its locker, and starts the watcher and
// the timeouts once, from the session's start. The lock key is the window manager's.
func TestItClaimsTheLockScreenSeatServingLockAndRequiresTheXDisplay(t *testing.T) {
m := readManifest(t)
if m.Module != "screen-lock" || m.Seats != nil {
t.Fatalf("module %q declares seats %v", m.Module, m.Seats)
}
if !reflect.DeepEqual(m.Claims, []claim{{Name: "node-lock-screen", Scope: "node", Serves: []string{"lock"}}}) {
t.Fatalf("claims: %+v", m.Claims)
}
if !reflect.DeepEqual(m.Requires, []string{"x11-display"}) {
t.Fatalf("requires: %v", m.Requires)
}
}
func TestTheDistributionsLockerReplacesTheColourBuildAndXscreensaverGoes(t *testing.T) {
m := readManifest(t)
present, absent := m.packages()
if !reflect.DeepEqual(present, []string{"xss-lock", "i3lock"}) || !reflect.DeepEqual(absent, []string{"i3lock-color", "xscreensaver"}) {
t.Fatalf("packages: %v, absent %v", present, absent)
}
m.sameAsSource(t, "wrapper", "files/bin/screen-lock")
wrapper := m.resource(t, "wrapper")
if wrapper["mode"] != "0755" || wrapper["path"] != "${machine:account-home}/.local/bin/screen-lock" {
t.Fatalf("the locker: %v", wrapper)
}
c := wrapper["content"].(string)
for _, colourOnly := range []string{"--ring-color", "--blur", "--clock", "--indicator", "--time-str"} {
if strings.Contains(c, colourOnly) {
t.Errorf("the wrapper passes %s, which only the colour build knows", colourOnly)
}
}
if !strings.Contains(c, "XSS_SLEEP_LOCK_FD}<&-") {
t.Error("the locker must not inherit the sleep lock")
}
}
func TestTheSessionStartSetsTheTimeoutsAndKeepsOneWatcherForTheSession(t *testing.T) {
m := readManifest(t)
if len(m.Shell) != 1 || m.Shell[0].For != "xinitrc" || m.Shell[0].Slot != "normal" {
t.Fatalf("one xinitrc contribution in normal: %+v", m.Shell)
}
code := m.Shell[0].Code
if strings.Count(code, "xss-lock --") != 1 || !strings.Contains(code, "--transfer-sleep-lock") ||
!strings.Contains(code, "while kill -0 $$") || !strings.HasSuffix(strings.TrimSpace(code), "&") {
t.Fatalf("the watcher: %q", code)
}
if strings.Contains(code, "xscreensaver") || strings.Contains(code, "my-i3lock") {
t.Fatalf("names what it replaced: %q", code)
}
for _, r := range m.Resources {
if r["type"] == "service" || r["type"] == "process" {
t.Fatalf("xss-lock needs the login session and is never a unit: %v", r)
}
}
}
func TestTheLockKeyIsTheWindowManagersNotThisModules(t *testing.T) {
m := readManifest(t)
// The i3 module binds $mod+Delete to logind's lock, which names no locker; a binding here as well
// would be i3's duplicate.
for _, r := range m.Resources {
if p, _ := r["path"].(string); strings.Contains(p, "i3/config.d") {
t.Fatalf("a key binding: %v", r)
}
}
}
func TestTheToolsAgreeWithTheManifest(t *testing.T) {
m := readManifest(t)
checkTheToolsAgree(t, m)
checkNoSecretsOrInstallationNames(t)
}
@@ -0,0 +1,423 @@
// The operator's graphical session, as a tool the node's runtime runs finds it (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 — no DISPLAY, no XAUTHORITY, no session bus. A tool that
// draws on the screen or talks to the desktop's D-Bus must find them. It reads them from a process of
// the account that is part of the session (the window manager first), the same thing `loginctl` and
// a person's own shell would point at, and says where it found them.
//
// Long-lived programs a tool starts go to the account's own service manager through `systemd-run
// --user`, never as children of the tool: the runtime's unit is a cgroup the service manager empties
// whenever the runtime restarts, and a compositor or a clipboard owner started from inside it would
// die with it.
//
// This file is the same in every desktop module that carries it; it moves into the Go SDK once a
// second consumer outside the desktop wants it.
package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
// Where the session is looked for. Variables so a test can point them at a fake tree.
var (
procRoot = "/proc"
runUserDir = "/run/user"
x11Sockets = "/tmp/.X11-unix"
)
// sessionHolders are the processes whose environment is the session's, best first: the window
// manager is the session, the rest are its children. Anything else carrying DISPLAY ranks after them.
var sessionHolders = []string{"i3", "sway", "i3bar", "picom", "xss-lock", "dunst", "clipmenud", "xterm"}
// sessionKeys are the variables a session carries that a tool hands on to what it runs.
var sessionKeys = []string{"DISPLAY", "XAUTHORITY", "WAYLAND_DISPLAY", "DBUS_SESSION_BUS_ADDRESS",
"XDG_RUNTIME_DIR", "XDG_SESSION_ID", "I3SOCK"}
// Session is what a tool needs to reach the operator's desktop.
type Session struct {
UID int `json:"uid"`
Display string `json:"display,omitempty"`
XAuthority string `json:"xauthority,omitempty"`
Wayland string `json:"wayland_display,omitempty"`
Bus string `json:"bus,omitempty"`
RuntimeDir string `json:"runtime_dir,omitempty"`
SessionID string `json:"session_id,omitempty"`
I3Sock string `json:"i3sock,omitempty"`
// From says where the values were found: the tool's own environment, a process, or the socket.
From string `json:"from"`
}
// ErrNoSession is answered by a tool that needs the desktop when nobody is logged in to it.
var ErrNoSession = errors.New("no graphical session")
// ErrTimedOut is what run answers for a command ended because it ran past its time.
var ErrTimedOut = errors.New("timed out")
// ErrNoBus is answered by a tool that needs the session bus when the account has none.
var ErrNoBus = errors.New("no session bus")
// 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
}
// findSession finds the graphical session of the account this tool runs as, or answers
// ErrNoSession with what it looked at.
func findSession() (Session, error) {
s := findEnvironment()
if s.Display == "" && s.Wayland == "" {
return s, fmt.Errorf("%w for uid %d on this machine: no process of the account carries DISPLAY "+
"or WAYLAND_DISPLAY, and no X server socket in %s has an authority file to go with it. "+
"Is anyone logged in to the desktop?", ErrNoSession, s.UID, x11Sockets)
}
return s, nil
}
// findBus finds the account's session bus, which a logged-in account has whether or not a desktop
// is running.
func findBus() (Session, error) {
s := findEnvironment()
if s.Bus == "" {
return s, fmt.Errorf("%w for uid %d: DBUS_SESSION_BUS_ADDRESS is not set and %s does not exist "+
"(the account is not logged in)", ErrNoBus, s.UID, filepath.Join(runUserDir, strconv.Itoa(s.UID), "bus"))
}
return s, nil
}
func findEnvironment() Session {
uid := os.Getuid()
s := Session{UID: uid}
own := map[string]string{}
for _, k := range sessionKeys {
own[k] = os.Getenv(k)
}
if own["DISPLAY"] != "" || own["WAYLAND_DISPLAY"] != "" {
s.fill(own)
s.From = "the tool's own environment"
} else if pid, comm, env, ok := sessionProcess(uid); ok {
s.fill(env)
s.From = fmt.Sprintf("process %s (pid %d)", comm, pid)
} else if display, ok := lonelyX11Socket(); ok {
if a := filepath.Join(operatorHome(), ".Xauthority"); exists(a) {
s.Display, s.XAuthority = display, a
s.From = "the X server socket and the account's ~/.Xauthority"
}
s.fill(own)
} else {
s.fill(own)
s.From = "nothing: no session found"
}
// The bus and the runtime directory are the account's, whether or not the process named them.
runtime := filepath.Join(runUserDir, strconv.Itoa(uid))
if s.RuntimeDir == "" && exists(runtime) {
s.RuntimeDir = runtime
}
if s.Bus == "" && s.RuntimeDir != "" && exists(filepath.Join(s.RuntimeDir, "bus")) {
s.Bus = "unix:path=" + filepath.Join(s.RuntimeDir, "bus")
}
return s
}
func (s *Session) fill(env map[string]string) {
set := func(dst *string, key string) {
if *dst == "" {
*dst = env[key]
}
}
set(&s.Display, "DISPLAY")
set(&s.XAuthority, "XAUTHORITY")
set(&s.Wayland, "WAYLAND_DISPLAY")
set(&s.Bus, "DBUS_SESSION_BUS_ADDRESS")
set(&s.RuntimeDir, "XDG_RUNTIME_DIR")
set(&s.SessionID, "XDG_SESSION_ID")
set(&s.I3Sock, "I3SOCK")
}
// sessionProcess is the best process of this uid whose environment names a display.
func sessionProcess(uid int) (int, string, map[string]string, bool) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return 0, "", nil, false
}
type candidate struct {
pid int
comm string
env map[string]string
rank int
}
var found []candidate
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
raw, err := os.ReadFile(filepath.Join(dir, "environ"))
if err != nil {
continue
}
env := parseEnviron(raw)
if env["DISPLAY"] == "" && env["WAYLAND_DISPLAY"] == "" {
continue
}
comm := readTrimmed(filepath.Join(dir, "comm"))
rank := len(sessionHolders)
for i, h := range sessionHolders {
if h == comm {
rank = i
break
}
}
found = append(found, candidate{pid, comm, env, rank})
}
if len(found) == 0 {
return 0, "", nil, false
}
sort.Slice(found, func(i, j int) bool {
if found[i].rank != found[j].rank {
return found[i].rank < found[j].rank
}
return found[i].pid > found[j].pid // the newer of two equals
})
best := found[0]
return best.pid, best.comm, best.env, true
}
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
}
func ownerOf(path string) (int, bool) {
info, err := os.Stat(path)
if err != nil {
return 0, false
}
st, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return int(st.Uid), true
}
// lonelyX11Socket is the display of the one X server socket there is, when there is exactly one.
func lonelyX11Socket() (string, bool) {
entries, err := os.ReadDir(x11Sockets)
if err != nil {
return "", false
}
var displays []string
for _, e := range entries {
if n := strings.TrimPrefix(e.Name(), "X"); n != e.Name() {
if _, err := strconv.Atoi(n); err == nil {
displays = append(displays, ":"+n)
}
}
}
if len(displays) != 1 {
return "", false
}
return displays[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
}
// Env is this process's environment with the session's variables in place of its own.
func (s Session) Env() []string {
drop := map[string]bool{}
for _, k := range sessionKeys {
drop[k] = true
}
var env []string
for _, kv := range os.Environ() {
if i := strings.IndexByte(kv, '='); i > 0 && drop[kv[:i]] {
continue
}
env = append(env, kv)
}
add := func(k, v string) {
if v != "" {
env = append(env, k+"="+v)
}
}
add("DISPLAY", s.Display)
add("XAUTHORITY", s.XAuthority)
add("WAYLAND_DISPLAY", s.Wayland)
add("DBUS_SESSION_BUS_ADDRESS", s.Bus)
add("XDG_RUNTIME_DIR", s.RuntimeDir)
add("XDG_SESSION_ID", s.SessionID)
add("I3SOCK", s.I3Sock)
return env
}
// mostOutput bounds what a command may answer with, per stream.
const mostOutput = 256 << 10
// Result is what a command did.
type Result struct {
Stdout string `json:"stdout"`
Stderr string `json:"stderr,omitempty"`
Code int `json:"code"`
Truncated bool `json:"truncated,omitempty"`
}
// run runs a command in the session's environment, its input given, ended with everything it
// started after timeout. A command that is not installed is an error naming it; one that exits
// non-zero is a Result with its code, for the caller to judge.
func (s Session) run(timeout time.Duration, stdin string, name string, args ...string) (Result, error) {
path, err := exec.LookPath(name)
if err != nil {
return Result{}, fmt.Errorf("%s is not installed on this machine", name)
}
cmd := exec.Command(path, args...)
cmd.Env = s.Env()
if home := operatorHome(); exists(home) {
cmd.Dir = home
}
if stdin != "" {
cmd.Stdin = strings.NewReader(stdin)
}
var out, errOut capped
cmd.Stdout, cmd.Stderr = &out, &errOut
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
if err := cmd.Start(); err != nil {
return Result{}, fmt.Errorf("%s: %w", name, err)
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
select {
case err = <-done:
case <-time.After(timeout):
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
return Result{Stdout: out.String(), Stderr: errOut.String()},
fmt.Errorf("%s did not finish within %s and was ended: %w", name, timeout, ErrTimedOut)
}
r := Result{Stdout: out.String(), Stderr: errOut.String(), Truncated: out.cut || errOut.cut}
var exit *exec.ExitError
if errors.As(err, &exit) {
r.Code = exit.ExitCode()
} else if err != nil {
return r, fmt.Errorf("%s: %w", name, err)
}
return r, nil
}
// detach starts a long-lived program under the account's own service manager, as a transient unit
// that carries the session's display, so it outlives the runtime that asked for it. A unit already
// running under the same name is stopped first, so a fixed name means "at most one".
func (s Session) detach(unit string, args ...string) error {
if s.RuntimeDir == "" {
return fmt.Errorf("%w: the account's runtime directory is missing, so its service manager "+
"cannot be reached", ErrNoBus)
}
_, _ = s.run(5*time.Second, "", "systemctl", "--user", "stop", unit+".service")
call := []string{"--user", "--collect", "--quiet", "--unit=" + unit}
for _, kv := range [][2]string{{"DISPLAY", s.Display}, {"XAUTHORITY", s.XAuthority},
{"WAYLAND_DISPLAY", s.Wayland}, {"XDG_SESSION_ID", s.SessionID}, {"I3SOCK", s.I3Sock}} {
if kv[1] != "" {
call = append(call, "--setenv="+kv[0]+"="+kv[1])
}
}
call = append(call, "--")
call = append(call, args...)
r, err := s.run(10*time.Second, "", "systemd-run", call...)
if err != nil {
return err
}
if r.Code != 0 {
return fmt.Errorf("systemd-run %s: %s", unit, strings.TrimSpace(r.Stderr))
}
return nil
}
// uniqueUnit is a transient unit name that will not collide with an earlier one.
func uniqueUnit(prefix string) string {
return fmt.Sprintf("%s-%d", prefix, time.Now().UnixNano())
}
type capped struct {
bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := mostOutput - c.Len(); room < len(p) {
if room > 0 {
c.Buffer.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.Buffer.Write(p)
}
// processesOf are the pids of this uid's processes whose command name is comm, oldest first.
func processesOf(comm string) []int {
entries, err := os.ReadDir(procRoot)
if err != nil {
return nil
}
uid := os.Getuid()
var pids []int
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
dir := filepath.Join(procRoot, e.Name())
if owner, ok := ownerOf(dir); !ok || owner != uid {
continue
}
if readTrimmed(filepath.Join(dir, "comm")) == comm {
pids = append(pids, pid)
}
}
sort.Ints(pids)
return pids
}
// signalAll sends sig to every process of this uid named comm, and answers the pids it reached.
func signalAll(comm string, sig syscall.Signal) []int {
var reached []int
for _, pid := range processesOf(comm) {
if syscall.Kill(pid, sig) == nil {
reached = append(reached, pid)
}
}
return reached
}
@@ -0,0 +1,174 @@
package main
import (
"errors"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
)
// fakeMachine points the session finder at a temporary /proc, /run/user and X socket directory, with
// none of the test process's own session variables, and gives back the root.
func fakeMachine(t *testing.T) string {
t.Helper()
root := t.TempDir()
procRoot, runUserDir, x11Sockets = filepath.Join(root, "proc"), filepath.Join(root, "run-user"), filepath.Join(root, "x11")
for _, d := range []string{procRoot, runUserDir, x11Sockets} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
for _, k := range sessionKeys {
t.Setenv(k, "")
}
t.Setenv("MESH_OPERATOR_HOME", filepath.Join(root, "home"))
t.Cleanup(func() { procRoot, runUserDir, x11Sockets = "/proc", "/run/user", "/tmp/.X11-unix" })
return root
}
func fakeProcess(t *testing.T, pid int, comm string, env ...string) {
t.Helper()
dir := filepath.Join(procRoot, strconv.Itoa(pid))
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "comm"), []byte(comm+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "environ"), []byte(strings.Join(env, "\x00")+"\x00"), 0o600); err != nil {
t.Fatal(err)
}
}
func TestTheSessionIsReadFromTheWindowManagerBeforeAnyOtherProcess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 900, "xterm", "DISPLAY=:9", "XAUTHORITY=/elsewhere")
fakeProcess(t, 100, "i3", "DISPLAY=:1", "XAUTHORITY=/home/op/.Xauthority",
"DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus", "XDG_SESSION_ID=3", "SECRET_TOKEN=never-copied")
fakeProcess(t, 50, "bash", "PATH=/usr/bin")
s, err := findSession()
if err != nil {
t.Fatal(err)
}
if s.Display != ":1" || s.XAuthority != "/home/op/.Xauthority" || s.SessionID != "3" || !strings.Contains(s.From, "i3 (pid 100)") {
t.Fatalf("the window manager's environment: %+v", s)
}
for _, kv := range s.Env() {
if strings.HasPrefix(kv, "SECRET_TOKEN=") {
t.Fatal("a variable of the session process that is not a session variable was handed on")
}
}
}
func TestAnyProcessCarryingADisplayServesWhenTheWindowManagerIsNotFound(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "firefox", "DISPLAY=:0")
fakeProcess(t, 20, "firefox", "DISPLAY=:2")
s, err := findSession()
if err != nil || s.Display != ":2" {
t.Fatalf("the newest of two equals: %+v, %v", s, err)
}
}
func TestNoSessionIsAClearAnswerNotAGuess(t *testing.T) {
fakeMachine(t)
fakeProcess(t, 10, "sshd", "PATH=/usr/bin")
_, err := findSession()
if !errors.Is(err, ErrNoSession) || !strings.Contains(err.Error(), "logged in to the desktop") {
t.Fatalf("no session: %v", err)
}
}
func TestOneXSocketAndTheAccountsAuthorityFileAreASession(t *testing.T) {
root := fakeMachine(t)
if err := os.WriteFile(filepath.Join(x11Sockets, "X0"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, "home"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "home", ".Xauthority"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findSession()
if err != nil || s.Display != ":0" || !strings.HasSuffix(s.XAuthority, "/home/.Xauthority") {
t.Fatalf("socket and authority: %+v, %v", s, err)
}
}
func TestTheBusIsTheAccountsRuntimeDirectoryWhenNoProcessNamesIt(t *testing.T) {
fakeMachine(t)
runtime := filepath.Join(runUserDir, strconv.Itoa(os.Getuid()))
if _, err := findBus(); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory is no bus: %v", err)
}
if err := os.MkdirAll(runtime, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(runtime, "bus"), nil, 0o600); err != nil {
t.Fatal(err)
}
s, err := findBus()
if err != nil || s.Bus != "unix:path="+filepath.Join(runtime, "bus") || s.RuntimeDir != runtime {
t.Fatalf("bus: %+v, %v", s, err)
}
env := strings.Join(s.Env(), "\n")
if !strings.Contains(env, "XDG_RUNTIME_DIR="+runtime) || !strings.Contains(env, "DBUS_SESSION_BUS_ADDRESS=unix:path=") {
t.Fatalf("the bus is handed on: %s", env)
}
}
func TestACommandIsBoundedAndANonZeroExitIsAResult(t *testing.T) {
fakeMachine(t)
s := Session{}
r, err := s.run(5*time.Second, "in", "sh", "-c", "cat; echo err >&2; exit 3")
if err != nil || r.Stdout != "in" || r.Code != 3 || strings.TrimSpace(r.Stderr) != "err" {
t.Fatalf("result: %+v, %v", r, err)
}
start := time.Now()
if _, err := s.run(200*time.Millisecond, "", "sh", "-c", "sleep 30 & sleep 30"); err == nil || time.Since(start) > 5*time.Second {
t.Fatalf("a command past its time is ended with what it started: %v after %s", err, time.Since(start))
}
if _, err := s.run(time.Second, "", "no-such-program-here"); err == nil || !strings.Contains(err.Error(), "not installed") {
t.Fatalf("a missing program: %v", err)
}
}
func TestDetachAsksTheAccountsServiceManagerWithTheSessionsDisplay(t *testing.T) {
fakeMachine(t)
bin := fakeBinaries(t, map[string]string{
"systemctl": `echo "systemctl $*" >> "$LOG"`,
"systemd-run": `echo "systemd-run $*" >> "$LOG"`,
})
log := filepath.Join(bin, "log")
t.Setenv("LOG", log)
s := Session{Display: ":1", XAuthority: "/x", RuntimeDir: "/run/user/1"}
if err := s.detach("picom-session", "picom", "--config", "/c"); err != nil {
t.Fatal(err)
}
got, _ := os.ReadFile(log)
want := "systemctl --user stop picom-session.service\n" +
"systemd-run --user --collect --quiet --unit=picom-session --setenv=DISPLAY=:1 --setenv=XAUTHORITY=/x -- picom --config /c\n"
if string(got) != want {
t.Fatalf("detach ran:\n%s\nwant:\n%s", got, want)
}
if err := (Session{}).detach("x", "y"); !errors.Is(err, ErrNoBus) {
t.Fatalf("no runtime directory: %v", err)
}
}
// fakeBinaries puts shell scripts named for programs first on PATH, and answers their directory.
func fakeBinaries(t *testing.T, scripts map[string]string) string {
t.Helper()
dir := t.TempDir()
for name, body := range scripts {
if err := os.WriteFile(filepath.Join(dir, name), []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
t.Fatal(err)
}
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return dir
}
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before
# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it.
#
# The distribution's i3lock: a black screen, failed attempts shown, an empty Enter ignored. The colour
# build the predecessor used is not in the distribution; it can come back as a pinned archive
# (ADR 0205), and then only these options change.
#
# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends
# once it is released. The locker must not inherit it, or the machine would wait for the unlock
# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is
# xss-lock's own documented pattern for i3lock.
set -u
options=(--color=000000 --show-failed-attempts --ignore-empty-password)
# One locker: a second press of the key, or a lock while locked, changes nothing.
if pgrep -xu "$EUID" i3lock >/dev/null; then
exit 0
fi
if [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then
kill_i3lock() { pkill -xu "$EUID" "$@" i3lock; }
trap kill_i3lock TERM INT
i3lock "${options[@]}" {XSS_SLEEP_LOCK_FD}<&-
exec {XSS_SLEEP_LOCK_FD}<&-
while kill_i3lock -0; do
sleep 0.5
done
else
trap 'kill %%' TERM INT
i3lock --nofork "${options[@]}" &
wait
fi
+5
View File
@@ -0,0 +1,5 @@
module screenlock
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=
+78
View File
@@ -0,0 +1,78 @@
{
"module": "screen-lock",
"version": "1",
"capabilities": [
"package-manager"
],
"requires": [
"x11-display"
],
"claims": [
{
"name": "node-lock-screen",
"scope": "node",
"serves": [
"lock"
]
}
],
"tools": [
"screen_lock_idle",
"screen_lock_inhibit",
"screen_lock_locked"
],
"shell": [
{
"for": "xinitrc",
"slot": "normal",
"code": "# The lock screen (module screen-lock, novox/hq ADR 0208): the session locks after 30 minutes idle,\n# the displays go to standby and suspend then and off after an hour, and xss-lock runs the locker on\n# idle, before suspend and on logind's Lock. xss-lock needs this login session, so it starts here and\n# not as a unit. It is started again if it exits, for as long as this session lasts ($$ is the\n# session's own process, which becomes the window manager).\nxset s 1800 1800\nxset dpms 1800 1800 3600\n(while kill -0 $$ 2>/dev/null; do xss-lock --transfer-sleep-lock -- \"$HOME/.local/bin/screen-lock\"; sleep 2; done) &\n"
}
],
"resources": [
{
"id": "watcher",
"type": "package",
"package": "xss-lock"
},
{
"id": "locker",
"type": "package",
"package": "i3lock"
},
{
"id": "colour-locker",
"type": "package",
"package": "i3lock-color",
"absent": true
},
{
"id": "screensaver",
"type": "package",
"package": "xscreensaver",
"absent": true
},
{
"id": "wrapper",
"type": "file",
"path": "${machine:account-home}/.local/bin/screen-lock",
"owner": "${machine:account}",
"mode": "0755",
"content": "#!/usr/bin/env bash\n# screen-lock (module screen-lock, novox/hq ADR 0208): the locker. xss-lock runs it on idle, before\n# suspend and on logind's Lock, and node-lock-screen's `lock` ends in it.\n#\n# The distribution's i3lock: a black screen, failed attempts shown, an empty Enter ignored. The colour\n# build the predecessor used is not in the distribution; it can come back as a pinned archive\n# (ADR 0205), and then only these options change.\n#\n# Before a suspend, xss-lock hands this script a lock on the sleep (XSS_SLEEP_LOCK_FD) and suspends\n# once it is released. The locker must not inherit it, or the machine would wait for the unlock\n# before sleeping; it is released once i3lock is up, so the machine never sleeps unlocked. This is\n# xss-lock's own documented pattern for i3lock.\nset -u\n\noptions=(--color=000000 --show-failed-attempts --ignore-empty-password)\n\n# One locker: a second press of the key, or a lock while locked, changes nothing.\nif pgrep -xu \"$EUID\" i3lock >/dev/null; then\n\texit 0\nfi\n\nif [[ -e /dev/fd/${XSS_SLEEP_LOCK_FD:--1} ]]; then\n\tkill_i3lock() { pkill -xu \"$EUID\" \"$@\" i3lock; }\n\ttrap kill_i3lock TERM INT\n\ti3lock \"${options[@]}\" {XSS_SLEEP_LOCK_FD}<&-\n\texec {XSS_SLEEP_LOCK_FD}<&-\n\twhile kill_i3lock -0; do\n\t\tsleep 0.5\n\tdone\nelse\n\ttrap 'kill %%' TERM INT\n\ti3lock --nofork \"${options[@]}\" &\n\twait\nfi\n"
}
],
"build": {
"artifacts": [
{
"name": "tools",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/screen-lock-tools",
"binary": "screen-lock-tools",
"loads": [
"screen-lock-tools"
]
}
]
}
}