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
81 uid := uint32(os.Getuid())
82 sockPath, err := control.DefaultPath()
83 if err != nil {
84 return err
85 }
86 controlCh := make(chan ports.ControlRequest, 8)
87 srv, err := control.Listen(sockPath, uid, controlCh, o.Log)
88 if err != nil {
89 return fmt.Errorf("control socket: %w", err)
90 }
91
92 cfgCh := make(chan ports.ConfigChanged, 4)
93 failCh := make(chan error, 4)
94 wlEvents := make(chan ports.WaylandEvent, 64)
95 wlCmds := make(chan ports.WaylandCommand, 16)
96 sysEvents := make(chan ports.SystemEvent, 16)
97 sysCmds := make(chan ports.SystemCommand, 16)
98 visEvents := make(chan ports.VisualEvent, 16)
99
100 launcher := &visualproc.Launcher{Executable: o.Executable, Args: []string{"visual"}, Log: o.Log}
101 visual := visualproc.NewManager(ctx, launcher, visEvents, o.Log)
102 owner, err := NewOwner(Options{Config: cfg, Visual: visual, Log: o.Log, Gate: true, ExpectSystem: true},
103 Inputs{Config: cfgCh, Failures: failCh, Wayland: wlEvents, System: sysEvents, Visual: visEvents, Control: controlCh},
104 Outputs{Wayland: wlCmds, System: sysCmds})
105 if err != nil {
106 return errors.Join(err, srv.Close())
107 }
108 started = true
109
110 var wg sync.WaitGroup
111 var mu sync.Mutex
112 var first error
113 fail := func(name string, err error) {
114 if err == nil || ctx.Err() != nil && errors.Is(err, context.Canceled) {
115 return
116 }
117 mu.Lock()
118 if first == nil {
119 first = fmt.Errorf("%s: %w", name, err)
120 }
121 mu.Unlock()
122 cancel()
123 }
124 spawn := func(name string, fn func() error) {
125 wg.Add(1)
126 go func() { defer wg.Done(); fail(name, fn()) }()
127 }
128 spawn("config watch", func() error { return config.Watch(ctx, path, cfgCh, failCh, nil) })
129 spawn("wayland", func() error { return wl.Run(ctx, wlCmds, wlEvents) })
130 spawn("logind", func() error { return sys.Run(ctx, sysCmds, sysEvents) })
131 spawn("control", func() error { return srv.Serve(ctx) })
132
133 ownerErr := owner.Run(ctx)
134 cancel()
135 wg.Wait()
136 log.Info().Msg("daemon stopped")
137 var unsup *UnsupportedError
138 if errors.As(ownerErr, &unsup) {
139 return ownerErr
140 }
141 mu.Lock()
142 defer mu.Unlock()
143 if ownerErr != nil {
144 return ownerErr
145 }
146 return first
147}
148
149func dialWayland() (net.Conn, error) {
150 name := os.Getenv("WAYLAND_DISPLAY")
151 if name == "" {
152 return nil, errors.New("WAYLAND_DISPLAY is not set")
153 }
154 if !filepath.IsAbs(name) {
155 dir := os.Getenv("XDG_RUNTIME_DIR")
156 if dir == "" {
157 return nil, errors.New("XDG_RUNTIME_DIR is not set")
158 }
159 name = filepath.Join(dir, name)
160 }
161 c, err := net.Dial("unix", name)
162 if err != nil {
163 return nil, fmt.Errorf("connect compositor: %w", err)
164 }
165 return c, nil
166}