The workstations' XDG conventions had no owner: xdg-user-dirs-update rewrote the folders file at every login, both machines' default-application lists named an editor neither has, and the laptop kept two dead links of the retired predecessor. The module adopts the operator's folders (three as settings, as the machines differ), disables the update so it cannot undo the mesh's file, and writes the machine's own mimeapps list, the last one read, so the person's choices in their own list always win. Autostart is listed, not owned: each entry is its application's module's.
235 lines
6.6 KiB
Go
235 lines
6.6 KiB
Go
package main
|
|
|
|
import (
|
|
"bufio"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
)
|
|
|
|
// Autostart is one XDG autostart entry, and what the session does with it.
|
|
type Autostart struct {
|
|
Entry
|
|
Scope string `json:"scope"` // user or system
|
|
// Overrides names the system entry of the same name this user entry hides; OverriddenBy the
|
|
// user entry hiding this system one.
|
|
Overrides string `json:"overrides,omitempty"`
|
|
OverriddenBy string `json:"overridden_by,omitempty"`
|
|
DexStarts bool `json:"dex_starts"`
|
|
Why string `json:"why"`
|
|
AlsoStarted string `json:"also_started_by,omitempty"`
|
|
Running int `json:"running"`
|
|
}
|
|
|
|
// dexDecides says whether `dex --autostart --environment <env>` starts an entry, by dex's own
|
|
// rules (dex 0.10): an entry of the same name in an earlier directory wins; Hidden=true, an
|
|
// OnlyShowIn without the environment, a NotShowIn with it, a TryExec that is not there, or a type
|
|
// other than Application keep it from starting. X-GNOME-Autostart-enabled and AutostartCondition
|
|
// are GNOME's, and dex does not read them.
|
|
func (m *Machine) dexDecides(a *Autostart, env string) {
|
|
switch {
|
|
case a.OverriddenBy != "":
|
|
a.Why = "hidden by the user entry " + a.OverriddenBy
|
|
case a.Broken != "":
|
|
a.Why = a.Broken
|
|
case a.Type != "" && a.Type != "Application":
|
|
a.Why = "type " + a.Type + ", not Application"
|
|
case a.Hidden:
|
|
a.Why = "Hidden=true"
|
|
case len(a.OnlyShow) > 0 && !contains(a.OnlyShow, env):
|
|
a.Why = "OnlyShowIn " + strings.Join(a.OnlyShow, ";") + " does not name " + env
|
|
case contains(a.NotShow, env):
|
|
a.Why = "NotShowIn names " + env
|
|
case a.TryExec != "" && !m.tryExecFound(a.TryExec):
|
|
a.Why = "TryExec " + a.TryExec + " is not installed"
|
|
default:
|
|
a.DexStarts = true
|
|
a.Why = "started at every login"
|
|
if !a.Found {
|
|
a.Why = "started at every login, and fails: " + a.Program + " is not installed"
|
|
}
|
|
if strings.EqualFold(a.GnomeEnabled, "false") {
|
|
a.Why += "; X-GNOME-Autostart-enabled=false does not stop dex (use Hidden=true)"
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Machine) tryExecFound(p string) bool {
|
|
_, ok := m.executable(p)
|
|
return ok
|
|
}
|
|
|
|
func contains(list []string, s string) bool {
|
|
for _, x := range list {
|
|
if strings.EqualFold(x, s) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// autostartDirs are the account's directory, then the system's, as dex reads them.
|
|
func (m *Machine) autostartDirs() []struct{ dir, scope string } {
|
|
out := []struct{ dir, scope string }{{filepath.Join(m.configHome(), "autostart"), "user"}}
|
|
for _, d := range m.configDirs() {
|
|
out = append(out, struct{ dir, scope string }{filepath.Join(d, "autostart"), "system"})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Autostarts lists every entry, user and system, with dex's decision for env.
|
|
func (m *Machine) Autostarts(env string) []Autostart {
|
|
var out []Autostart
|
|
first := map[string]int{}
|
|
procs := m.processes()
|
|
starts := m.windowManagerStarts()
|
|
for _, d := range m.autostartDirs() {
|
|
entries, _ := os.ReadDir(m.p(d.dir))
|
|
for _, de := range entries {
|
|
if !strings.HasSuffix(de.Name(), ".desktop") {
|
|
continue
|
|
}
|
|
a := Autostart{Entry: m.readEntry(filepath.Join(d.dir, de.Name())), Scope: d.scope}
|
|
if i, seen := first[de.Name()]; seen {
|
|
a.OverriddenBy = out[i].File
|
|
out[i].Overrides = a.File
|
|
} else {
|
|
first[de.Name()] = len(out)
|
|
}
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
for i := range out {
|
|
a := &out[i]
|
|
m.dexDecides(a, env)
|
|
a.Running = procs.count(a.Program)
|
|
if by, ok := starts[filepath.Base(a.Program)]; ok && a.Program != "" {
|
|
a.AlsoStarted = by
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// procs are the account's processes: each one's first two words, base names only, and its name.
|
|
type procs [][3]string
|
|
|
|
// processes reads the account's own processes from /proc; nothing but the command line and the
|
|
// process's name is read.
|
|
func (m *Machine) processes() procs {
|
|
var out procs
|
|
entries, _ := os.ReadDir(m.p("/proc"))
|
|
self := strconv.Itoa(os.Getpid())
|
|
for _, e := range entries {
|
|
if _, err := strconv.Atoi(e.Name()); err != nil || e.Name() == self {
|
|
continue
|
|
}
|
|
dir := m.p(filepath.Join("/proc", e.Name()))
|
|
info, err := os.Stat(dir)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if st, ok := info.Sys().(*syscall.Stat_t); !ok || int(st.Uid) != m.UID {
|
|
continue
|
|
}
|
|
raw, err := os.ReadFile(filepath.Join(dir, "cmdline"))
|
|
if err != nil || len(raw) == 0 {
|
|
continue
|
|
}
|
|
words := strings.Split(strings.TrimRight(string(raw), "\x00"), "\x00")
|
|
if len(words) == 1 {
|
|
// Chromium and Electron rewrite their command line as one string, words joined by spaces.
|
|
words = strings.Fields(words[0])
|
|
}
|
|
if len(words) == 0 {
|
|
continue
|
|
}
|
|
var p [3]string
|
|
if comm, err := os.ReadFile(filepath.Join(dir, "comm")); err == nil {
|
|
p[2] = strings.TrimSpace(string(comm))
|
|
}
|
|
p[0] = filepath.Base(words[0])
|
|
if len(words) > 1 {
|
|
p[1] = filepath.Base(words[1])
|
|
}
|
|
out = append(out, p)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// count is how many processes run a program: as their command, or as the script an interpreter
|
|
// runs (a Python applet is "python3 /usr/bin/applet").
|
|
func (ps procs) count(program string) int {
|
|
if program == "" {
|
|
return 0
|
|
}
|
|
name := filepath.Base(program)
|
|
n := 0
|
|
for _, p := range ps {
|
|
// The kernel keeps a process's name to 15 bytes.
|
|
if p[0] == name || p[1] == name && isInterpreter(p[0]) || p[2] != "" && p[2] == truncate(name, 15) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) > n {
|
|
return s[:n]
|
|
}
|
|
return s
|
|
}
|
|
|
|
func isInterpreter(cmd string) bool {
|
|
for _, i := range []string{"python", "perl", "sh", "bash", "node", "ruby"} {
|
|
if strings.HasPrefix(cmd, i) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// windowManagerStarts are the programs the window manager's configuration starts itself, by base
|
|
// name, with the line that does it. Read only: the configuration is the window manager's module's.
|
|
func (m *Machine) windowManagerStarts() map[string]string {
|
|
out := map[string]string{}
|
|
files := []string{filepath.Join(m.configHome(), "i3", "config")}
|
|
more, _ := filepath.Glob(m.p(filepath.Join(m.configHome(), "i3", "config.d", "*.conf")))
|
|
sort.Strings(more)
|
|
for _, f := range more {
|
|
files = append(files, strings.TrimPrefix(f, m.Root))
|
|
}
|
|
for _, f := range files {
|
|
h, err := os.Open(m.p(f))
|
|
if err != nil {
|
|
continue
|
|
}
|
|
sc := bufio.NewScanner(h)
|
|
n := 0
|
|
for sc.Scan() {
|
|
n++
|
|
words := strings.Fields(sc.Text())
|
|
if len(words) < 2 || words[0] != "exec" && words[0] != "exec_always" {
|
|
continue
|
|
}
|
|
rest := words[1:]
|
|
for len(rest) > 0 && strings.HasPrefix(rest[0], "--") {
|
|
rest = rest[1:]
|
|
}
|
|
if len(rest) == 0 {
|
|
continue
|
|
}
|
|
prog := filepath.Base(Program(strings.Join(rest, " ")))
|
|
if prog == "dex" {
|
|
continue
|
|
}
|
|
out[prog] = m.tilde(f) + ":" + strconv.Itoa(n)
|
|
}
|
|
h.Close()
|
|
}
|
|
return out
|
|
}
|