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:
@@ -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
|
||||
}
|
||||
Executable
+34
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
module screenlock
|
||||
|
||||
go 1.22
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -0,0 +1,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"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user