1package app
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "net"
8 "os"
9 "path/filepath"
10 "sync"
11
12 "github.com/bnema/neferafk/internal/adapters/config"
13 "github.com/bnema/neferafk/internal/adapters/control"
14 "github.com/bnema/neferafk/internal/adapters/logind"
15 "github.com/bnema/neferafk/internal/adapters/visualproc"
16 "github.com/bnema/neferafk/internal/adapters/wayland"
17 "github.com/bnema/neferafk/internal/ports"
18 "github.com/bnema/zerowrap"
19)
20
21// DaemonOptions configures RunDaemon.
22type DaemonOptions struct {
23 ConfigPath string // empty: config.DefaultPath()
24 // Executable is re-executed as `<Executable> visual` for the visual process.
25 Executable string
26 Log zerowrap.Logger
27}
28
29// RunDaemon wires the real adapters around one Owner. It returns nil on a
30// requested shutdown, *UnsupportedError for missing capabilities, or the first
31// fatal adapter error. Nothing in the daemon initialises Vulkan or fonts.
32func RunDaemon(ctx context.Context, o DaemonOptions) (result error) {
33 log := o.Log.WithField("component", "daemon")
34 path := o.ConfigPath
35 if path == "" {
36 path = config.DefaultPath()
37 }
38 cfg, err := config.Load(path)
39 switch {
40 case errors.Is(err, os.ErrNotExist):
41 cfg = ports.Defaults()
42 case err != nil:
43 return fmt.Errorf("config: %w", err)
44 }
45 ctx, cancel := context.WithCancel(ctx)
46 defer cancel()
47
48 wlReq, sysReq := Requirements(cfg)
49 conn, err := dialWayland()
50 if err != nil {
51 return &UnsupportedError{Missing: []string{"wayland: " + err.Error()}}
52 }
53 // Until the adapters own their resources (started), every early return
54 // releases the compositor connection and logind, joining Close errors.
55 started := false
56 var sys *logind.Adapter
57 defer func() {
58 if started {
59 return
60 }
61 if e := conn.Close(); e != nil && !errors.Is(e, net.ErrClosed) {
62 result = errors.Join(result, fmt.Errorf("close wayland connection: %w", e))
63 }
64 if sys != nil {
65 if e := sys.Close(); e != nil {
66 result = errors.Join(result, fmt.Errorf("close logind: %w", e))
67 }
68 }
69 }()
70 // Initial deadlines come from the owner's first ArmIdle (SetDeadlines).
71 wl, err := wayland.New(conn, wayland.Options{Requirements: wlReq, Generation: 1}, o.Log)
72 if err != nil {
73 return fmt.Errorf("wayland: %w", err)
74 }
75 sys, err = logind.Open(ctx, sysReq)
76 if err != nil {
77 sys = nil
78 return &UnsupportedError{Missing: []string{"logind: " + err.Error()}}
79 }
80 if path, source := sys.Session(); path != "" {
81 log.Info().Str("session", path).Str("source", source).Msg("logind session bound")
82 }
83
84 uid := uint32(os.Getuid())
85 sockPath, err := control.DefaultPath()
86 if err != nil {
87 return err
88 }
89 controlCh := make(chan ports.ControlRequest, 8)
90 srv, err := control.Listen(sockPath, uid, controlCh, o.Log)
91 if err != nil {
92 return fmt.Errorf("control socket: %w", err)
93 }
94
95 cfgCh := make(chan ports.ConfigChanged, 4)
96 failCh := make(chan error, 4)
97 wlEvents := make(chan ports.WaylandEvent, 64)
98 wlCmds := make(chan ports.WaylandCommand, 16)
99 sysEvents := make(chan ports.SystemEvent, 16)
100 sysCmds := make(chan ports.SystemCommand, 16)
101 visEvents := make(chan ports.VisualEvent, 16)
102
103 launcher := &visualproc.Launcher{Executable: o.Executable, Args: []string{"visual"}, Log: o.Log}
104 visual := visualproc.NewManager(ctx, launcher, visEvents, o.Log)
105 owner, err := NewOwner(Options{Config: cfg, Visual: visual, Log: o.Log, Gate: true, ExpectSystem: true},
106 Inputs{Config: cfgCh, Failures: failCh, Wayland: wlEvents, System: sysEvents, Visual: visEvents, Control: controlCh},
107 Outputs{Wayland: wlCmds, System: sysCmds})
108 if err != nil {
109 return errors.Join(err, srv.Close())
110 }
111 started = true
112
113 var wg sync.WaitGroup
114 var mu sync.Mutex
115 var first error
116 fail := func(name string, err error) {
117 if err == nil || ctx.Err() != nil && errors.Is(err, context.Canceled) {
118 return
119 }
120 mu.Lock()
121 if first == nil {
122 first = fmt.Errorf("%s: %w", name, err)
123 }
124 mu.Unlock()
125 cancel()
126 }
127 spawn := func(name string, fn func() error) {
128 wg.Add(1)
129 go func() { defer wg.Done(); fail(name, fn()) }()
130 }
131 spawn("config watch", func() error { return config.Watch(ctx, path, cfgCh, failCh, nil) })
132 spawn("wayland", func() error { return wl.Run(ctx, wlCmds, wlEvents) })
133 spawn("logind", func() error { return sys.Run(ctx, sysCmds, sysEvents) })
134 spawn("control", func() error { return srv.Serve(ctx) })
135
136 ownerErr := owner.Run(ctx)
137 cancel()
138 wg.Wait()
139 log.Info().Msg("daemon stopped")
140 var unsup *UnsupportedError
141 if errors.As(ownerErr, &unsup) {
142 return ownerErr
143 }
144 mu.Lock()
145 defer mu.Unlock()
146 if ownerErr != nil {
147 return ownerErr
148 }
149 return first
150}
151
152func dialWayland() (net.Conn, error) {
153 name := os.Getenv("WAYLAND_DISPLAY")
154 if name == "" {
155 return nil, errors.New("WAYLAND_DISPLAY is not set")
156 }
157 if !filepath.IsAbs(name) {
158 dir := os.Getenv("XDG_RUNTIME_DIR")
159 if dir == "" {
160 return nil, errors.New("XDG_RUNTIME_DIR is not set")
161 }
162 name = filepath.Join(dir, name)
163 }
164 c, err := net.Dial("unix", name)
165 if err != nil {
166 return nil, fmt.Errorf("connect compositor: %w", err)
167 }
168 return c, nil
169}