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.
210 lines
6.0 KiB
Go
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
|
|
}
|