Files
mesh-catalog/modules/screen-lock/cmd/screen-lock-tools/lock.go
T
jochen 04f3f66f4b 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.
2026-10-04 13:10:58 +02:00

338 lines
10 KiB
Go

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)
}