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:15:39 +02:00
parent 8bbea4a2ad
commit 0fa90e5cf2
13 changed files with 1749 additions and 0 deletions
@@ -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)
}
}