Files
mesh-catalog/modules/xdg/cmd/xdg-tools/machine.go
T
jochen 785d32407b xdg: own the account's folders and the machine's default applications
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.
2026-10-05 11:50:34 +02:00

210 lines
6.0 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
)
// Bounds on a command a tool runs: well below the runtime's 30 s call limit, and an answer that
// fits in a tool's reply.
const (
callTimeout = 5 * time.Second
outputCap = 64 << 10
)
// Runner runs a command with an environment and answers its standard output; a failure carries
// what the command said on standard error. Injected, so the tools are tested without a machine.
type Runner func(ctx context.Context, env []string, name string, args ...string) (string, error)
// Machine is everything the tools read. Root is "" on the machine and a fake tree in tests; every
// other path is as the machine names it.
type Machine struct {
Root string
Home string
UID int
Desktop string // the session's XDG_CURRENT_DESKTOP, lower case
Run Runner
Timeout time.Duration
}
// NewMachine is the real machine, as the operator's account the runtime serves the tools as.
func NewMachine() *Machine {
home := os.Getenv("MESH_OPERATOR_HOME")
if home == "" {
home, _ = os.UserHomeDir()
}
desktop := strings.ToLower(os.Getenv("XDG_CURRENT_DESKTOP"))
if desktop == "" {
// The window manager's session says i3; the runtime is not in the session, so it says nothing.
desktop = "i3"
}
return &Machine{Home: home, UID: os.Getuid(), Desktop: desktop, Run: run, Timeout: callTimeout}
}
// run is the machine's Runner: the command in its own process group, killed with everything it
// started at the deadline, each stream cut at outputCap.
func run(ctx context.Context, env []string, name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
cmd.Env = env
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
out, errb := &capped{}, &capped{}
cmd.Stdout, cmd.Stderr = out, errb
if err := cmd.Start(); err != nil {
return "", err
}
done := make(chan error, 1)
go func() { done <- cmd.Wait() }()
var err error
select {
case err = <-done:
case <-ctx.Done():
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
<-done
return out.String(), fmt.Errorf("%s did not finish in time", name)
}
if err != nil {
said := strings.TrimSpace(errb.String())
if said == "" {
said = strings.TrimSpace(out.String())
}
var exit *exec.ExitError
if errors.As(err, &exit) {
return out.String(), fmt.Errorf("%s exited %d: %s", name, exit.ExitCode(), said)
}
return out.String(), fmt.Errorf("%s: %v", name, err)
}
return out.String(), nil
}
// capped keeps the first outputCap bytes written to it.
type capped struct {
buf bytes.Buffer
cut bool
}
func (c *capped) Write(p []byte) (int, error) {
if room := outputCap - c.buf.Len(); room < len(p) {
if room > 0 {
c.buf.Write(p[:room])
}
c.cut = true
return len(p), nil
}
return c.buf.Write(p)
}
func (c *capped) String() string { return c.buf.String() }
// cmd runs one command, bounded, with the account's XDG environment.
func (m *Machine) cmd(name string, args ...string) (string, error) {
t := m.Timeout
if t == 0 {
t = callTimeout
}
ctx, cancel := context.WithTimeout(context.Background(), t)
defer cancel()
return m.Run(ctx, m.env(), name, args...)
}
// env is what xdg-mime and gio are given: the account's home and the session's data directories,
// so they look where the session looks. Nothing else of the runtime's environment is passed on.
func (m *Machine) env() []string {
return []string{
"HOME=" + m.Home,
"PATH=" + strings.Join(m.pathDirs(), ":"),
"LANG=C.UTF-8",
"XDG_CONFIG_HOME=" + m.configHome(),
"XDG_DATA_HOME=" + m.dataHome(),
"XDG_DATA_DIRS=" + strings.Join(m.dataDirs(), ":"),
"XDG_CURRENT_DESKTOP=" + m.Desktop,
}
}
// p is a machine path inside Root.
func (m *Machine) p(path string) string { return filepath.Join(m.Root, path) }
func (m *Machine) configHome() string { return filepath.Join(m.Home, ".config") }
func (m *Machine) dataHome() string { return filepath.Join(m.Home, ".local", "share") }
// configDirs are the system's configuration directories: XDG_CONFIG_DIRS is unset in both
// workstations' sessions or says only this.
func (m *Machine) configDirs() []string { return []string{"/etc/xdg"} }
// dataDirs are the session's XDG_DATA_DIRS on the workstations, those that exist.
func (m *Machine) dataDirs() []string {
candidates := []string{
filepath.Join(m.Home, ".local", "share", "flatpak", "exports", "share"),
"/var/lib/flatpak/exports/share",
"/usr/local/share",
"/usr/share",
"/var/lib/snapd/desktop",
}
var out []string
for _, d := range candidates {
if d == "/usr/share" || d == "/usr/local/share" || m.isDir(d) {
out = append(out, d)
}
}
return out
}
// pathDirs is where a program is looked for: the account's own bin, then the system's.
func (m *Machine) pathDirs() []string {
return []string{filepath.Join(m.Home, ".local", "bin"), "/usr/local/sbin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/lib/flatpak/bin"}
}
func (m *Machine) isDir(path string) bool {
info, err := os.Stat(m.p(path))
return err == nil && info.IsDir()
}
func (m *Machine) read(path string) (string, bool) {
b, err := os.ReadFile(m.p(path))
if err != nil {
return "", false
}
return string(b), true
}
// executable is whether a program is there to run: an absolute path that is an executable file,
// or a name found on the path.
func (m *Machine) executable(program string) (string, bool) {
if program == "" {
return "", false
}
if strings.Contains(program, "/") {
return program, isExecutable(m.p(program))
}
for _, d := range m.pathDirs() {
full := filepath.Join(d, program)
if isExecutable(m.p(full)) {
return full, true
}
}
return program, false
}
func isExecutable(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir() && info.Mode()&0o111 != 0
}
// tilde writes a path under the home as ~/…, which is how the README and the person name it.
func (m *Machine) tilde(path string) string {
if path == m.Home {
return "~"
}
if strings.HasPrefix(path, m.Home+"/") {
return "~/" + strings.TrimPrefix(path, m.Home+"/")
}
return path
}