1package launcher
2
3import (
4 "context"
5 "fmt"
6 "os"
7 "os/exec"
8 "path/filepath"
9 "sort"
10 "strconv"
11 "strings"
12 "syscall"
13
14 "github.com/bnema/neferwl/internal/ports"
15 "github.com/bnema/zerowrap"
16)
17
18type Launcher struct {
19 env []string
20 log zerowrap.Logger
21}
22
23func New(env []string, log zerowrap.Logger) *Launcher {
24 return &Launcher{env: append([]string(nil), env...), log: log}
25}
26
27func ChildEnv(base []string, waylandDisplay, runtimeDir string, cursorSize int) []string {
28 values := map[string]string{}
29 for _, entry := range base {
30 key, value, ok := strings.Cut(entry, "=")
31 if !ok {
32 continue
33 }
34 switch {
35 case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", key == "DBUS_SESSION_BUS_ADDRESS", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"), strings.HasPrefix(key, "XCURSOR_"):
36 values[key] = value
37 }
38 }
39 values["WAYLAND_DISPLAY"] = waylandDisplay
40 values["XDG_RUNTIME_DIR"] = runtimeDir
41 values["XDG_CURRENT_DESKTOP"] = "neferwl"
42 values["XDG_SESSION_TYPE"] = "wayland"
43 // X11 apps (through Xwayland) and toolkits pick their own cursor size:
44 // give them the compositor's.
45 values["XCURSOR_SIZE"] = strconv.Itoa(cursorSize)
46 keys := make([]string, 0, len(values))
47 for key := range values {
48 keys = append(keys, key)
49 }
50 sort.Strings(keys)
51 env := make([]string, 0, len(keys))
52 for _, key := range keys {
53 env = append(env, key+"="+values[key])
54 }
55 return env
56}
57
58func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) error {
59 for {
60 select {
61 case <-ctx.Done():
62 return nil
63 case req, ok := <-reqs:
64 if !ok {
65 return nil
66 }
67 if len(req.Argv) == 0 {
68 l.log.Warn().Msg("empty spawn argv")
69 continue
70 }
71 path, err := childLookPath(req.Argv[0], l.env)
72 if err != nil {
73 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
74 continue
75 }
76 cmd := exec.Command(path, req.Argv[1:]...)
77 cmd.Env = append(append([]string(nil), l.env...), req.Env...)
78 cmd.Stdin = nil
79 cmd.Stdout = nil
80 cmd.Stderr = nil
81 cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
82 if err := cmd.Start(); err != nil {
83 l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
84 continue
85 }
86 // Children have independent sessions and can outlive Run; reaping continues until process exit.
87 go func() { l.log.Debug().Err(cmd.Wait()).Str("binary", req.Argv[0]).Msg("child exited") }()
88 }
89 }
90}
91
92func childLookPath(name string, env []string) (string, error) {
93 if strings.Contains(name, "/") {
94 return name, nil
95 }
96 path := ""
97 for _, entry := range env {
98 if strings.HasPrefix(entry, "PATH=") {
99 path = strings.TrimPrefix(entry, "PATH=")
100 }
101 }
102 for _, dir := range filepath.SplitList(path) {
103 if dir == "" {
104 dir = "."
105 }
106 candidate := filepath.Join(dir, name)
107 info, err := os.Stat(candidate)
108 if err == nil && !info.IsDir() && info.Mode().Perm()&0111 != 0 {
109 return candidate, nil
110 }
111 }
112 return "", fmt.Errorf("executable %q not found in child PATH", name)
113}