detached c900986

refactor(cli): move the command line from main.go into internal/cli (#131)

* refactor(cli): move the command line from main.go into internal/cli

main.go only calls cli.Main. A small wrapper over the standard flag package
selects the command, prints errors and usage once and maps them to exit
codes. validate-config, state and version are Command values; the root
command keeps its flags, validation and compositor startup.

* fix(cli): print compositor errors once and validate --debug before startup

A run error is already in the run log, which goes to stderr on a terminal,
so exitCode no longer prints it again. --debug is checked with the other
flags, so a bad name in the config file is no longer reported as a usage
error. tuneGC runs right before logging starts, and mergeDebug returns a
single configured category without copying it. The tests check stdout and
stderr for every command, including state and validate-config warnings.

* fix(cli): keep run errors on stderr when it is not a terminal

The run log only reaches stderr on a terminal, so exitCode skips printing a
run error only there; under systemd or a greeter the error still reaches
stderr. The command tests also require empty stdout on error paths.
commit
c90098690d17c497313e915c361989e3f2040ca8
author
bnema · · 2026-10-04 15:12 UTC
committer
GitHub · · 2026-10-04 15:12 UTC
parent
3b84a9a
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
7 files · +724 -437
M .hexcheck.yaml +1 -1
@@ -5,7 +5,7 @@ components:
55 # sessionlock is the display owner's proof ledger, not core placement policy.
66 adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] }
77 # app is the composition root: it wires adapters to core, like cmd.
8- entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }
8+ entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**, internal/cli/**] }
99 generated: { role: ignore, paths: [internal/mocks/**] }
1010 rules:
1111 no-adapter-imports-in-core: error
@@ -5,7 +5,7 @@ components:
5 # sessionlock is the display owner's proof ledger, not core placement policy.5 # sessionlock is the display owner's proof ledger, not core placement policy.
6 adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] }6 adapters: { role: adapter, paths: [internal/adapters/**, internal/logging/**, internal/sessionlock/**] }
7 # app is the composition root: it wires adapters to core, like cmd.7 # app is the composition root: it wires adapters to core, like cmd.
8- entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**] }8+ entrypoints: { role: entrypoint, paths: [cmd/**, internal/app/**, internal/cli/**] }
9 generated: { role: ignore, paths: [internal/mocks/**] }9 generated: { role: ignore, paths: [internal/mocks/**] }
10 rules:10 rules:
11 no-adapter-imports-in-core: error11 no-adapter-imports-in-core: error
M cmd/neferwl/main.go +4 -338
@@ -1,350 +1,16 @@
1+// Command neferwl is the NeferWL Wayland compositor. The command line lives
2+// in internal/cli; the compositor wiring in internal/app.
13 package main
24
35 import (
4- "context"
5- "encoding/json"
6- "errors"
7- "flag"
8- "fmt"
9- "io"
10- "net"
11- "net/http"
12- "net/http/pprof"
136 "os"
14- "os/signal"
15- "runtime/debug"
16- "strconv"
17- "strings"
18- "syscall"
19- "time"
207
21- "github.com/bnema/neferwl/internal/adapters/config"
22- "github.com/bnema/neferwl/internal/adapters/statefile"
23- "github.com/bnema/neferwl/internal/app"
24- "github.com/bnema/neferwl/internal/logging"
25- "github.com/bnema/neferwl/internal/ports"
8+ "github.com/bnema/neferwl/internal/cli"
269 )
2710
28-type usageError struct{ error }
29-
30-// openScript opens the --input script. It returns a nil interface, not a
31-// typed-nil *os.File, when no script was requested: app.Run tests the
32-// interface against nil to decide whether to read a script. closeScript
33-// closes what openScript opened; it does nothing for stdin and for no script.
34-func openScript(path string) (script io.ReadCloser, closeScript func(), err error) {
35- switch path {
36- case "":
37- return nil, func() {}, nil
38- case "-":
39- return os.Stdin, func() {}, nil
40- }
41- f, err := os.Open(path)
42- if err != nil {
43- return nil, func() {}, err
44- }
45- return f, func() { _ = f.Close() }, nil
46-}
47-
4811 // version is set with -ldflags "-X main.version=..." by packaged builds.
4912 var version string
5013
5114 func main() {
52- tuneGC()
53- os.Exit(runCode())
54-}
55-
56-// tuneGC bounds the heap of a process that runs for days. The Go live heap
57-// is small (tens of MB; buffers live in GPU and shared memory), so the
58-// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
59-// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
60-// more heap. A soft limit makes the collector work harder before the
61-// process gets large. GOGC and GOMEMLIMIT set in the environment win.
62-func tuneGC() {
63- if os.Getenv("GOMEMLIMIT") == "" {
64- debug.SetMemoryLimit(256 << 20)
65- }
66-}
67-
68-// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
69-// done. It binds before returning so a bad address fails at startup.
70-func servePprof(ctx context.Context, addr string) error {
71- ln, err := net.Listen("tcp", addr)
72- if err != nil {
73- return fmt.Errorf("--pprof: %w", err)
74- }
75- mux := http.NewServeMux()
76- mux.HandleFunc("/debug/pprof/", pprof.Index)
77- mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
78- mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
79- mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
80- mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
81- srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
82- go func() { _ = srv.Serve(ln) }()
83- go func() {
84- <-ctx.Done()
85- _ = srv.Close()
86- }()
87- return nil
88-}
89-
90-func runCode() int {
91- err := run()
92- if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {
93- return 0
94- }
95- var usage usageError
96- if errors.As(err, &usage) {
97- return 2
98- }
99- return 1
100-}
101-
102-func mergeDebug(configured []string, cli string) string {
103- if cli == "" {
104- return strings.Join(configured, ",")
105- }
106- if len(configured) == 0 {
107- return cli
108- }
109- return strings.Join(configured, ",") + "," + cli
110-}
111-
112-func run() error {
113- if len(os.Args) > 1 && os.Args[1] == "validate-config" {
114- if len(os.Args) > 3 {
115- err := usageError{fmt.Errorf("usage: validate-config [path]")}
116- fmt.Fprintln(os.Stderr, err)
117- return err
118- }
119- path := config.DefaultPath()
120- load := config.LoadDefault
121- if len(os.Args) == 3 {
122- path = os.Args[2]
123- load = func() (ports.Config, []config.Warning, error) { return config.Load(path) }
124- }
125- _, warnings, err := load()
126- if err != nil {
127- fmt.Fprintln(os.Stderr, err)
128- return err
129- }
130- for _, w := range warnings {
131- fmt.Fprintf(os.Stderr, "%s:%s\n", path, w)
132- }
133- if len(warnings) > 0 {
134- return fmt.Errorf("%d warning(s)", len(warnings))
135- }
136- fmt.Println("ok: " + path)
137- return nil
138- }
139- if len(os.Args) > 1 && os.Args[1] == "state" {
140- return runState(os.Args[2:])
141- }
142- if len(os.Args) == 2 && os.Args[1] == "version" {
143- info, ok := debug.ReadBuildInfo()
144- if !ok {
145- fmt.Println("unknown")
146- return nil
147- }
148- v := info.Main.Version
149- if version != "" {
150- v = version // set by packaged builds (PKGBUILD -X main.version)
151- } else if v == "" {
152- v = "(devel)"
153- }
154- fmt.Println(v)
155- return nil
156- }
157- flags := flag.NewFlagSet("neferwl", flag.ContinueOnError)
158- backend := flags.String("backend", "drm", "drm or headless")
159- screenshot := flags.String("screenshot", "", "write PNG frames to directory")
160- size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
161- inputPath := flags.String("input", "", "headless input script path (- for stdin)")
162- noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
163- noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
164- headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
165- screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
166- timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
167- debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
168- configFlag := flags.String("config", "", "config path (empty uses XDG default)")
169- session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
170- pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
171- if err := flags.Parse(os.Args[1:]); err != nil {
172- if errors.Is(err, flag.ErrHelp) {
173- return err
174- }
175- return usageError{err}
176- }
177- if *backend != "drm" && *backend != "headless" {
178- err := usageError{fmt.Errorf("invalid backend %q", *backend)}
179- fmt.Fprintln(os.Stderr, err)
180- return err
181- }
182- if *headlessHDR && *backend != "headless" {
183- return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")}
184- }
185- if *screenshot != "" && *backend != "headless" {
186- err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}
187- fmt.Fprintln(os.Stderr, err)
188- return err
189- }
190- if *screenshotRaw && (*screenshot == "" || !*headlessHDR) {
191- err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")}
192- fmt.Fprintln(os.Stderr, err)
193- return err
194- }
195- var sizes [][2]int
196- for _, s := range strings.Split(*size, ",") {
197- w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s))
198- if sizeErr != nil || w == 0 {
199- err := usageError{fmt.Errorf("--size: %w", sizeErr)}
200- if sizeErr == nil {
201- err = usageError{fmt.Errorf("--size: need WxH, got %q", s)}
202- }
203- fmt.Fprintln(os.Stderr, err)
204- return err
205- }
206- sizes = append(sizes, [2]int{w, h})
207- }
208- if *inputPath != "" && *backend != "headless" {
209- err := usageError{fmt.Errorf("--input requires --backend=headless")}
210- fmt.Fprintln(os.Stderr, err)
211- return err
212- }
213- if *timeout < 0 {
214- err := usageError{fmt.Errorf("negative timeout")}
215- fmt.Fprintln(os.Stderr, err)
216- return err
217- }
218- if flags.NArg() != 0 {
219- err := usageError{fmt.Errorf("unexpected arguments: %v", flags.Args())}
220- fmt.Fprintln(os.Stderr, err)
221- return err
222- }
223- if *screenshot != "" {
224- if err := os.MkdirAll(*screenshot, 0o755); err != nil {
225- fmt.Fprintln(os.Stderr, err)
226- return err
227- }
228- }
229- path := *configFlag
230- var cfgErr error
231- var cfg = config.Defaults()
232- var warnings []config.Warning
233- if path == "" {
234- path = config.DefaultPath()
235- cfg, warnings, cfgErr = config.LoadDefault()
236- } else {
237- cfg, warnings, cfgErr = config.Load(path)
238- }
239- if cfgErr != nil {
240- fmt.Fprintln(os.Stderr, cfgErr)
241- return cfgErr
242- }
243- script, closeScript, err := openScript(*inputPath)
244- if err != nil {
245- return err
246- }
247- // app.Run closes the script on shutdown; this covers the returns before it.
248- defer closeScript()
249- selected := mergeDebug(cfg.Log.Debug, *debugFlag)
250- if _, err := logging.ParseDebug(selected); err != nil {
251- fmt.Fprintln(os.Stderr, err)
252- return usageError{err}
253- }
254- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
255- defer stop()
256- ctx, closeLog, err := logging.Open(ctx, *backend, cfg.Log.Level, selected)
257- if err != nil {
258- fmt.Fprintln(os.Stderr, err)
259- return err
260- }
261- defer closeLog()
262- log := logging.For(ctx, "app")
263- defer func() {
264- if p := recover(); p != nil {
265- log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
266- _ = closeLog()
267- panic(p)
268- }
269- }()
270- if *pprofAddr != "" {
271- if err := servePprof(ctx, *pprofAddr); err != nil {
272- fmt.Fprintln(os.Stderr, err)
273- return err
274- }
275- log.Info().Str("addr", *pprofAddr).Msg("pprof")
276- }
277- start := time.Now()
278- log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
279- configLog := logging.For(ctx, "config")
280- configLog.Info().Str("path", path).Msg("loaded config")
281- for _, w := range warnings {
282- configLog.Warn().Int("line", w.Line).Msg(w.Msg)
283- }
284- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script})
285- // SIGINT and SIGTERM cancel the context and are clean exits.
286- if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
287- err = nil
288- }
289- reason := "clean"
290- switch {
291- case err != nil:
292- reason = "error"
293- case ctx.Err() != nil:
294- reason = "signal"
295- case *timeout > 0 && time.Since(start) >= *timeout:
296- reason = "timeout"
297- }
298- log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
299- return err
300-}
301-
302-// runState prints the session state for scripts:
303-//
304-// neferwl state the whole state
305-// neferwl state output-of <pid> the output showing that process's window
306-//
307-// The file is $NEFERWL_STATE, else the one of $WAYLAND_DISPLAY.
308-func runState(args []string) error {
309- usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")}
310- pid := 0
311- switch {
312- case len(args) == 0:
313- case len(args) == 2 && args[0] == "output-of":
314- n, err := strconv.Atoi(args[1])
315- if err != nil || n <= 0 {
316- fmt.Fprintln(os.Stderr, usage)
317- return usage
318- }
319- pid = n
320- default:
321- fmt.Fprintln(os.Stderr, usage)
322- return usage
323- }
324- path := os.Getenv(statefile.Env)
325- if path == "" {
326- var err error
327- if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil {
328- fmt.Fprintln(os.Stderr, err)
329- return err
330- }
331- }
332- st, err := statefile.Read(path)
333- if err != nil {
334- fmt.Fprintln(os.Stderr, err)
335- return err
336- }
337- var out any = st
338- if pid != 0 {
339- if out, err = statefile.OutputOf(st, pid); err != nil {
340- fmt.Fprintln(os.Stderr, err)
341- return err
342- }
343- }
344- data, err := json.Marshal(out)
345- if err != nil {
346- return err
347- }
348- fmt.Println(string(data))
349- return nil
15+ os.Exit(cli.Main(os.Args[1:], version))
35016 }
@@ -1,350 +1,16 @@
1+// Command neferwl is the NeferWL Wayland compositor. The command line lives
2+// in internal/cli; the compositor wiring in internal/app.
1 package main3 package main
2 4
3 import (5 import (
4- "context"
5- "encoding/json"
6- "errors"
7- "flag"
8- "fmt"
9- "io"
10- "net"
11- "net/http"
12- "net/http/pprof"
13 "os"6 "os"
14- "os/signal"
15- "runtime/debug"
16- "strconv"
17- "strings"
18- "syscall"
19- "time"
20 7
21- "github.com/bnema/neferwl/internal/adapters/config"8+ "github.com/bnema/neferwl/internal/cli"
22- "github.com/bnema/neferwl/internal/adapters/statefile"
23- "github.com/bnema/neferwl/internal/app"
24- "github.com/bnema/neferwl/internal/logging"
25- "github.com/bnema/neferwl/internal/ports"
26 )9 )
27 10
28-type usageError struct{ error }
29-
30-// openScript opens the --input script. It returns a nil interface, not a
31-// typed-nil *os.File, when no script was requested: app.Run tests the
32-// interface against nil to decide whether to read a script. closeScript
33-// closes what openScript opened; it does nothing for stdin and for no script.
34-func openScript(path string) (script io.ReadCloser, closeScript func(), err error) {
35- switch path {
36- case "":
37- return nil, func() {}, nil
38- case "-":
39- return os.Stdin, func() {}, nil
40- }
41- f, err := os.Open(path)
42- if err != nil {
43- return nil, func() {}, err
44- }
45- return f, func() { _ = f.Close() }, nil
46-}
47-
48 // version is set with -ldflags "-X main.version=..." by packaged builds.11 // version is set with -ldflags "-X main.version=..." by packaged builds.
49 var version string12 var version string
50 13
51 func main() {14 func main() {
52- tuneGC()15+ os.Exit(cli.Main(os.Args[1:], version))
53- os.Exit(runCode())
54-}
55-
56-// tuneGC bounds the heap of a process that runs for days. The Go live heap
57-// is small (tens of MB; buffers live in GPU and shared memory), so the
58-// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
59-// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
60-// more heap. A soft limit makes the collector work harder before the
61-// process gets large. GOGC and GOMEMLIMIT set in the environment win.
62-func tuneGC() {
63- if os.Getenv("GOMEMLIMIT") == "" {
64- debug.SetMemoryLimit(256 << 20)
65- }
66-}
67-
68-// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
69-// done. It binds before returning so a bad address fails at startup.
70-func servePprof(ctx context.Context, addr string) error {
71- ln, err := net.Listen("tcp", addr)
72- if err != nil {
73- return fmt.Errorf("--pprof: %w", err)
74- }
75- mux := http.NewServeMux()
76- mux.HandleFunc("/debug/pprof/", pprof.Index)
77- mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
78- mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
79- mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
80- mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
81- srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
82- go func() { _ = srv.Serve(ln) }()
83- go func() {
84- <-ctx.Done()
85- _ = srv.Close()
86- }()
87- return nil
88-}
89-
90-func runCode() int {
91- err := run()
92- if err == nil || errors.Is(err, flag.ErrHelp) || errors.Is(err, context.Canceled) {
93- return 0
94- }
95- var usage usageError
96- if errors.As(err, &usage) {
97- return 2
98- }
99- return 1
100-}
101-
102-func mergeDebug(configured []string, cli string) string {
103- if cli == "" {
104- return strings.Join(configured, ",")
105- }
106- if len(configured) == 0 {
107- return cli
108- }
109- return strings.Join(configured, ",") + "," + cli
110-}
111-
112-func run() error {
113- if len(os.Args) > 1 && os.Args[1] == "validate-config" {
114- if len(os.Args) > 3 {
115- err := usageError{fmt.Errorf("usage: validate-config [path]")}
116- fmt.Fprintln(os.Stderr, err)
117- return err
118- }
119- path := config.DefaultPath()
120- load := config.LoadDefault
121- if len(os.Args) == 3 {
122- path = os.Args[2]
123- load = func() (ports.Config, []config.Warning, error) { return config.Load(path) }
124- }
125- _, warnings, err := load()
126- if err != nil {
127- fmt.Fprintln(os.Stderr, err)
128- return err
129- }
130- for _, w := range warnings {
131- fmt.Fprintf(os.Stderr, "%s:%s\n", path, w)
132- }
133- if len(warnings) > 0 {
134- return fmt.Errorf("%d warning(s)", len(warnings))
135- }
136- fmt.Println("ok: " + path)
137- return nil
138- }
139- if len(os.Args) > 1 && os.Args[1] == "state" {
140- return runState(os.Args[2:])
141- }
142- if len(os.Args) == 2 && os.Args[1] == "version" {
143- info, ok := debug.ReadBuildInfo()
144- if !ok {
145- fmt.Println("unknown")
146- return nil
147- }
148- v := info.Main.Version
149- if version != "" {
150- v = version // set by packaged builds (PKGBUILD -X main.version)
151- } else if v == "" {
152- v = "(devel)"
153- }
154- fmt.Println(v)
155- return nil
156- }
157- flags := flag.NewFlagSet("neferwl", flag.ContinueOnError)
158- backend := flags.String("backend", "drm", "drm or headless")
159- screenshot := flags.String("screenshot", "", "write PNG frames to directory")
160- size := flags.String("size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
161- inputPath := flags.String("input", "", "headless input script path (- for stdin)")
162- noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
163- noXwayland := flags.Bool("no-xwayland", false, "no X11 display for X11 apps")
164- headlessHDR := flags.Bool("headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
165- screenshotRaw := flags.Bool("screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
166- timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
167- debugFlag := flags.String("debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
168- configFlag := flags.String("config", "", "config path (empty uses XDG default)")
169- session := flags.Bool("session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
170- pprofAddr := flags.String("pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
171- if err := flags.Parse(os.Args[1:]); err != nil {
172- if errors.Is(err, flag.ErrHelp) {
173- return err
174- }
175- return usageError{err}
176- }
177- if *backend != "drm" && *backend != "headless" {
178- err := usageError{fmt.Errorf("invalid backend %q", *backend)}
179- fmt.Fprintln(os.Stderr, err)
180- return err
181- }
182- if *headlessHDR && *backend != "headless" {
183- return usageError{fmt.Errorf("--headless-hdr requires --backend=headless")}
184- }
185- if *screenshot != "" && *backend != "headless" {
186- err := usageError{fmt.Errorf("--screenshot requires --backend=headless")}
187- fmt.Fprintln(os.Stderr, err)
188- return err
189- }
190- if *screenshotRaw && (*screenshot == "" || !*headlessHDR) {
191- err := usageError{fmt.Errorf("--screenshot-raw requires --screenshot and --headless-hdr")}
192- fmt.Fprintln(os.Stderr, err)
193- return err
194- }
195- var sizes [][2]int
196- for _, s := range strings.Split(*size, ",") {
197- w, h, _, sizeErr := config.ParseMode(strings.TrimSpace(s))
198- if sizeErr != nil || w == 0 {
199- err := usageError{fmt.Errorf("--size: %w", sizeErr)}
200- if sizeErr == nil {
201- err = usageError{fmt.Errorf("--size: need WxH, got %q", s)}
202- }
203- fmt.Fprintln(os.Stderr, err)
204- return err
205- }
206- sizes = append(sizes, [2]int{w, h})
207- }
208- if *inputPath != "" && *backend != "headless" {
209- err := usageError{fmt.Errorf("--input requires --backend=headless")}
210- fmt.Fprintln(os.Stderr, err)
211- return err
212- }
213- if *timeout < 0 {
214- err := usageError{fmt.Errorf("negative timeout")}
215- fmt.Fprintln(os.Stderr, err)
216- return err
217- }
218- if flags.NArg() != 0 {
219- err := usageError{fmt.Errorf("unexpected arguments: %v", flags.Args())}
220- fmt.Fprintln(os.Stderr, err)
221- return err
222- }
223- if *screenshot != "" {
224- if err := os.MkdirAll(*screenshot, 0o755); err != nil {
225- fmt.Fprintln(os.Stderr, err)
226- return err
227- }
228- }
229- path := *configFlag
230- var cfgErr error
231- var cfg = config.Defaults()
232- var warnings []config.Warning
233- if path == "" {
234- path = config.DefaultPath()
235- cfg, warnings, cfgErr = config.LoadDefault()
236- } else {
237- cfg, warnings, cfgErr = config.Load(path)
238- }
239- if cfgErr != nil {
240- fmt.Fprintln(os.Stderr, cfgErr)
241- return cfgErr
242- }
243- script, closeScript, err := openScript(*inputPath)
244- if err != nil {
245- return err
246- }
247- // app.Run closes the script on shutdown; this covers the returns before it.
248- defer closeScript()
249- selected := mergeDebug(cfg.Log.Debug, *debugFlag)
250- if _, err := logging.ParseDebug(selected); err != nil {
251- fmt.Fprintln(os.Stderr, err)
252- return usageError{err}
253- }
254- ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
255- defer stop()
256- ctx, closeLog, err := logging.Open(ctx, *backend, cfg.Log.Level, selected)
257- if err != nil {
258- fmt.Fprintln(os.Stderr, err)
259- return err
260- }
261- defer closeLog()
262- log := logging.For(ctx, "app")
263- defer func() {
264- if p := recover(); p != nil {
265- log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
266- _ = closeLog()
267- panic(p)
268- }
269- }()
270- if *pprofAddr != "" {
271- if err := servePprof(ctx, *pprofAddr); err != nil {
272- fmt.Fprintln(os.Stderr, err)
273- return err
274- }
275- log.Info().Str("addr", *pprofAddr).Msg("pprof")
276- }
277- start := time.Now()
278- log.Info().Strs("args", os.Args[1:]).Str("backend", *backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
279- configLog := logging.For(ctx, "config")
280- configLog.Info().Str("path", path).Msg("loaded config")
281- for _, w := range warnings {
282- configLog.Warn().Int("line", w.Line).Msg(w.Msg)
283- }
284- err = app.Run(ctx, app.Options{Backend: *backend, Config: cfg, ConfigPath: path, Timeout: *timeout, NoTerminal: *noTerminal, NoXwayland: *noXwayland, HeadlessHDR: *headlessHDR, Session: *session, ScreenshotDir: *screenshot, ScreenshotRaw: *screenshotRaw, Sizes: sizes, Script: script})
285- // SIGINT and SIGTERM cancel the context and are clean exits.
286- if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
287- err = nil
288- }
289- reason := "clean"
290- switch {
291- case err != nil:
292- reason = "error"
293- case ctx.Err() != nil:
294- reason = "signal"
295- case *timeout > 0 && time.Since(start) >= *timeout:
296- reason = "timeout"
297- }
298- log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
299- return err
300-}
301-
302-// runState prints the session state for scripts:
303-//
304-// neferwl state the whole state
305-// neferwl state output-of <pid> the output showing that process's window
306-//
307-// The file is $NEFERWL_STATE, else the one of $WAYLAND_DISPLAY.
308-func runState(args []string) error {
309- usage := usageError{fmt.Errorf("usage: state [output-of <pid>]")}
310- pid := 0
311- switch {
312- case len(args) == 0:
313- case len(args) == 2 && args[0] == "output-of":
314- n, err := strconv.Atoi(args[1])
315- if err != nil || n <= 0 {
316- fmt.Fprintln(os.Stderr, usage)
317- return usage
318- }
319- pid = n
320- default:
321- fmt.Fprintln(os.Stderr, usage)
322- return usage
323- }
324- path := os.Getenv(statefile.Env)
325- if path == "" {
326- var err error
327- if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil {
328- fmt.Fprintln(os.Stderr, err)
329- return err
330- }
331- }
332- st, err := statefile.Read(path)
333- if err != nil {
334- fmt.Fprintln(os.Stderr, err)
335- return err
336- }
337- var out any = st
338- if pid != 0 {
339- if out, err = statefile.OutputOf(st, pid); err != nil {
340- fmt.Fprintln(os.Stderr, err)
341- return err
342- }
343- }
344- data, err := json.Marshal(out)
345- if err != nil {
346- return err
347- }
348- fmt.Println(string(data))
349- return nil
350 }16 }
D cmd/neferwl/main_test.go +0 -98
@@ -1,98 +0,0 @@
1-package main
2-
3-import (
4- "io"
5- "os"
6- "strings"
7- "testing"
8-)
9-
10-func TestMergeDebug(t *testing.T) {
11- if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" {
12- t.Fatal(got)
13- }
14- if got := mergeDebug(nil, ""); got != "" {
15- t.Fatal(got)
16- }
17-}
18-func TestExitCodes(t *testing.T) {
19- for _, tc := range []struct {
20- args []string
21- code int
22- }{
23- {[]string{"neferwl", "--help"}, 0},
24- {[]string{"neferwl", "--backend=invalid"}, 2},
25- {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
26- {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2},
27- {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2},
28- {[]string{"neferwl", "--timeout=-1s"}, 2},
29- {[]string{"neferwl", "extra"}, 2},
30- {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1},
31- {[]string{"neferwl", "validate-config", "/nonexistent/neferwl.conf"}, 1},
32- } {
33- old := os.Args
34- os.Args = tc.args
35- got := runCode()
36- os.Args = old
37- if got != tc.code {
38- t.Errorf("%v: %d want %d", tc.args, got, tc.code)
39- }
40- }
41- if !strings.Contains(mergeDebug([]string{"core"}, "render"), "render") {
42- t.Fatal("union")
43- }
44-}
45-
46-func TestOpenScript(t *testing.T) {
47- file := t.TempDir() + "/script"
48- if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil {
49- t.Fatal(err)
50- }
51- for _, tc := range []struct {
52- name, path string
53- check func(t *testing.T, script io.ReadCloser, err error)
54- }{
55- {"none", "", func(t *testing.T, script io.ReadCloser, err error) {
56- if err != nil || script != nil {
57- t.Fatalf("got %#v, %v; want a nil interface", script, err)
58- }
59- }},
60- {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) {
61- if err != nil || script != io.ReadCloser(os.Stdin) {
62- t.Fatalf("got %#v, %v; want os.Stdin", script, err)
63- }
64- }},
65- {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) {
66- if err == nil || script != nil {
67- t.Fatalf("got %#v, %v; want a nil interface and an error", script, err)
68- }
69- }},
70- {"file", file, func(t *testing.T, script io.ReadCloser, err error) {
71- if err != nil || script == nil {
72- t.Fatalf("got %#v, %v", script, err)
73- }
74- if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" {
75- t.Fatalf("read %q, %v", got, err)
76- }
77- }},
78- } {
79- t.Run(tc.name, func(t *testing.T) {
80- script, closeScript, err := openScript(tc.path)
81- if closeScript == nil {
82- t.Fatal("nil close func")
83- }
84- tc.check(t, script, err)
85- closeScript()
86- if tc.path == "-" {
87- if _, err := os.Stdin.Stat(); err != nil {
88- t.Fatalf("stdin closed: %v", err)
89- }
90- }
91- if tc.name == "file" {
92- if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF {
93- t.Fatalf("file left open: %v", err)
94- }
95- }
96- })
97- }
98-}
@@ -1,98 +0,0 @@
1-package main
2-
3-import (
4- "io"
5- "os"
6- "strings"
7- "testing"
8-)
9-
10-func TestMergeDebug(t *testing.T) {
11- if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" {
12- t.Fatal(got)
13- }
14- if got := mergeDebug(nil, ""); got != "" {
15- t.Fatal(got)
16- }
17-}
18-func TestExitCodes(t *testing.T) {
19- for _, tc := range []struct {
20- args []string
21- code int
22- }{
23- {[]string{"neferwl", "--help"}, 0},
24- {[]string{"neferwl", "--backend=invalid"}, 2},
25- {[]string{"neferwl", "--backend=drm", "--screenshot=/tmp/shots"}, 2},
26- {[]string{"neferwl", "--backend=headless", "--screenshot=/tmp/shots", "--screenshot-raw"}, 2},
27- {[]string{"neferwl", "--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2},
28- {[]string{"neferwl", "--timeout=-1s"}, 2},
29- {[]string{"neferwl", "extra"}, 2},
30- {[]string{"neferwl", "--config=/nonexistent/neferwl.conf"}, 1},
31- {[]string{"neferwl", "validate-config", "/nonexistent/neferwl.conf"}, 1},
32- } {
33- old := os.Args
34- os.Args = tc.args
35- got := runCode()
36- os.Args = old
37- if got != tc.code {
38- t.Errorf("%v: %d want %d", tc.args, got, tc.code)
39- }
40- }
41- if !strings.Contains(mergeDebug([]string{"core"}, "render"), "render") {
42- t.Fatal("union")
43- }
44-}
45-
46-func TestOpenScript(t *testing.T) {
47- file := t.TempDir() + "/script"
48- if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil {
49- t.Fatal(err)
50- }
51- for _, tc := range []struct {
52- name, path string
53- check func(t *testing.T, script io.ReadCloser, err error)
54- }{
55- {"none", "", func(t *testing.T, script io.ReadCloser, err error) {
56- if err != nil || script != nil {
57- t.Fatalf("got %#v, %v; want a nil interface", script, err)
58- }
59- }},
60- {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) {
61- if err != nil || script != io.ReadCloser(os.Stdin) {
62- t.Fatalf("got %#v, %v; want os.Stdin", script, err)
63- }
64- }},
65- {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) {
66- if err == nil || script != nil {
67- t.Fatalf("got %#v, %v; want a nil interface and an error", script, err)
68- }
69- }},
70- {"file", file, func(t *testing.T, script io.ReadCloser, err error) {
71- if err != nil || script == nil {
72- t.Fatalf("got %#v, %v", script, err)
73- }
74- if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" {
75- t.Fatalf("read %q, %v", got, err)
76- }
77- }},
78- } {
79- t.Run(tc.name, func(t *testing.T) {
80- script, closeScript, err := openScript(tc.path)
81- if closeScript == nil {
82- t.Fatal("nil close func")
83- }
84- tc.check(t, script, err)
85- closeScript()
86- if tc.path == "-" {
87- if _, err := os.Stdin.Stat(); err != nil {
88- t.Fatalf("stdin closed: %v", err)
89- }
90- }
91- if tc.name == "file" {
92- if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF {
93- t.Fatalf("file left open: %v", err)
94- }
95- }
96- })
97- }
98-}
A internal/cli/cli.go +139 -0
@@ -0,0 +1,139 @@
1+// Package cli parses the neferwl command line and runs the selected command.
2+package cli
3+
4+import (
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+)
12+
13+// Env holds what a command uses besides its arguments.
14+type Env struct {
15+ Stdout, Stderr io.Writer
16+ // Version is the packaged build version; empty for source builds.
17+ Version string
18+}
19+
20+// Runner runs a command with its positional arguments, after flag parsing.
21+type Runner func(ctx context.Context, env Env, args []string) error
22+
23+// Command is one neferwl command. Setup registers the command's flags on fs
24+// and returns its Runner, which reads the parsed flag values.
25+type Command struct {
26+ Name string // empty for the root command
27+ Args string // positional arguments synopsis, e.g. "[path]"
28+ Summary string
29+ Setup func(fs *flag.FlagSet) Runner
30+}
31+
32+// root runs the compositor; commands are the subcommands.
33+var (
34+ root = Command{Args: "[flags]", Summary: "Run the compositor.", Setup: setupRun}
35+ commands = []Command{validateConfigCmd, stateCmd, versionCmd}
36+)
37+
38+// Main runs the command line args (without the program name) and returns
39+// the process exit code: 0 on success, 2 on usage errors, 1 otherwise.
40+func Main(args []string, version string) int {
41+ env := Env{Stdout: os.Stdout, Stderr: os.Stderr, Version: version}
42+ return exitCode(env.Stderr, Execute(context.Background(), env, args))
43+}
44+
45+// Execute selects the command named by args[0], or the root command, parses
46+// its flags and runs it. -h and --help print the command usage to Stdout.
47+func Execute(ctx context.Context, env Env, args []string) error {
48+ cmd := lookup(args)
49+ if cmd.Name != "" {
50+ args = args[1:]
51+ }
52+ fs := flag.NewFlagSet(cmd.path(), flag.ContinueOnError)
53+ fs.SetOutput(io.Discard) // errors and usage are printed once, here and in exitCode
54+ run := cmd.Setup(fs)
55+ if err := fs.Parse(args); err != nil {
56+ if errors.Is(err, flag.ErrHelp) {
57+ printUsage(env.Stdout, cmd, fs)
58+ return nil
59+ }
60+ return cmd.usageError(err)
61+ }
62+ err := run(ctx, env, fs.Args())
63+ var usage usageError
64+ if errors.As(err, &usage) {
65+ return cmd.usageError(usage.error)
66+ }
67+ return err
68+}
69+
70+func lookup(args []string) Command {
71+ if len(args) > 0 {
72+ for _, c := range commands {
73+ if args[0] == c.Name {
74+ return c
75+ }
76+ }
77+ }
78+ return root
79+}
80+
81+func (c Command) path() string {
82+ if c.Name == "" {
83+ return "neferwl"
84+ }
85+ return "neferwl " + c.Name
86+}
87+
88+func (c Command) synopsis() string {
89+ if c.Args == "" {
90+ return c.path()
91+ }
92+ return c.path() + " " + c.Args
93+}
94+
95+// usageError marks a bad command line; it exits with code 2.
96+type usageError struct{ error }
97+
98+// usagef returns a usage error; Execute appends the command synopsis.
99+func usagef(format string, a ...any) error {
100+ return usageError{fmt.Errorf(format, a...)}
101+}
102+
103+func (c Command) usageError(err error) error {
104+ return usageError{fmt.Errorf("%w\nusage: %s (see %s --help)", err, c.synopsis(), c.path())}
105+}
106+
107+func printUsage(w io.Writer, cmd Command, fs *flag.FlagSet) {
108+ fmt.Fprintf(w, "usage: %s\n\n%s\n", cmd.synopsis(), cmd.Summary)
109+ if cmd.Name == "" {
110+ fmt.Fprintln(w, "\nCommands:")
111+ for _, c := range commands {
112+ fmt.Fprintf(w, " %-34s %s\n", c.synopsis(), c.Summary)
113+ }
114+ }
115+ hasFlags := false
116+ fs.VisitAll(func(*flag.Flag) { hasFlags = true })
117+ if hasFlags {
118+ fmt.Fprintln(w, "\nFlags:")
119+ fs.SetOutput(w)
120+ fs.PrintDefaults()
121+ }
122+}
123+
124+// exitCode prints err to w, unless the run log already has it, and maps
125+// it to the process exit code. A canceled context (SIGINT, SIGTERM) is a
126+// clean exit.
127+func exitCode(w io.Writer, err error) int {
128+ if err == nil || errors.Is(err, context.Canceled) {
129+ return 0
130+ }
131+ if !errors.As(err, new(loggedError)) {
132+ fmt.Fprintln(w, err)
133+ }
134+ var usage usageError
135+ if errors.As(err, &usage) {
136+ return 2
137+ }
138+ return 1
139+}
@@ -0,0 +1,139 @@
1+// Package cli parses the neferwl command line and runs the selected command.
2+package cli
3+
4+import (
5+ "context"
6+ "errors"
7+ "flag"
8+ "fmt"
9+ "io"
10+ "os"
11+)
12+
13+// Env holds what a command uses besides its arguments.
14+type Env struct {
15+ Stdout, Stderr io.Writer
16+ // Version is the packaged build version; empty for source builds.
17+ Version string
18+}
19+
20+// Runner runs a command with its positional arguments, after flag parsing.
21+type Runner func(ctx context.Context, env Env, args []string) error
22+
23+// Command is one neferwl command. Setup registers the command's flags on fs
24+// and returns its Runner, which reads the parsed flag values.
25+type Command struct {
26+ Name string // empty for the root command
27+ Args string // positional arguments synopsis, e.g. "[path]"
28+ Summary string
29+ Setup func(fs *flag.FlagSet) Runner
30+}
31+
32+// root runs the compositor; commands are the subcommands.
33+var (
34+ root = Command{Args: "[flags]", Summary: "Run the compositor.", Setup: setupRun}
35+ commands = []Command{validateConfigCmd, stateCmd, versionCmd}
36+)
37+
38+// Main runs the command line args (without the program name) and returns
39+// the process exit code: 0 on success, 2 on usage errors, 1 otherwise.
40+func Main(args []string, version string) int {
41+ env := Env{Stdout: os.Stdout, Stderr: os.Stderr, Version: version}
42+ return exitCode(env.Stderr, Execute(context.Background(), env, args))
43+}
44+
45+// Execute selects the command named by args[0], or the root command, parses
46+// its flags and runs it. -h and --help print the command usage to Stdout.
47+func Execute(ctx context.Context, env Env, args []string) error {
48+ cmd := lookup(args)
49+ if cmd.Name != "" {
50+ args = args[1:]
51+ }
52+ fs := flag.NewFlagSet(cmd.path(), flag.ContinueOnError)
53+ fs.SetOutput(io.Discard) // errors and usage are printed once, here and in exitCode
54+ run := cmd.Setup(fs)
55+ if err := fs.Parse(args); err != nil {
56+ if errors.Is(err, flag.ErrHelp) {
57+ printUsage(env.Stdout, cmd, fs)
58+ return nil
59+ }
60+ return cmd.usageError(err)
61+ }
62+ err := run(ctx, env, fs.Args())
63+ var usage usageError
64+ if errors.As(err, &usage) {
65+ return cmd.usageError(usage.error)
66+ }
67+ return err
68+}
69+
70+func lookup(args []string) Command {
71+ if len(args) > 0 {
72+ for _, c := range commands {
73+ if args[0] == c.Name {
74+ return c
75+ }
76+ }
77+ }
78+ return root
79+}
80+
81+func (c Command) path() string {
82+ if c.Name == "" {
83+ return "neferwl"
84+ }
85+ return "neferwl " + c.Name
86+}
87+
88+func (c Command) synopsis() string {
89+ if c.Args == "" {
90+ return c.path()
91+ }
92+ return c.path() + " " + c.Args
93+}
94+
95+// usageError marks a bad command line; it exits with code 2.
96+type usageError struct{ error }
97+
98+// usagef returns a usage error; Execute appends the command synopsis.
99+func usagef(format string, a ...any) error {
100+ return usageError{fmt.Errorf(format, a...)}
101+}
102+
103+func (c Command) usageError(err error) error {
104+ return usageError{fmt.Errorf("%w\nusage: %s (see %s --help)", err, c.synopsis(), c.path())}
105+}
106+
107+func printUsage(w io.Writer, cmd Command, fs *flag.FlagSet) {
108+ fmt.Fprintf(w, "usage: %s\n\n%s\n", cmd.synopsis(), cmd.Summary)
109+ if cmd.Name == "" {
110+ fmt.Fprintln(w, "\nCommands:")
111+ for _, c := range commands {
112+ fmt.Fprintf(w, " %-34s %s\n", c.synopsis(), c.Summary)
113+ }
114+ }
115+ hasFlags := false
116+ fs.VisitAll(func(*flag.Flag) { hasFlags = true })
117+ if hasFlags {
118+ fmt.Fprintln(w, "\nFlags:")
119+ fs.SetOutput(w)
120+ fs.PrintDefaults()
121+ }
122+}
123+
124+// exitCode prints err to w, unless the run log already has it, and maps
125+// it to the process exit code. A canceled context (SIGINT, SIGTERM) is a
126+// clean exit.
127+func exitCode(w io.Writer, err error) int {
128+ if err == nil || errors.Is(err, context.Canceled) {
129+ return 0
130+ }
131+ if !errors.As(err, new(loggedError)) {
132+ fmt.Fprintln(w, err)
133+ }
134+ var usage usageError
135+ if errors.As(err, &usage) {
136+ return 2
137+ }
138+ return 1
139+}
A internal/cli/cli_test.go +182 -0
@@ -0,0 +1,182 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "os"
9+ "strconv"
10+ "strings"
11+ "testing"
12+)
13+
14+// execute runs args like Main and returns the exit code and output.
15+func execute(t *testing.T, args ...string) (code int, stdout, stderr string) {
16+ t.Helper()
17+ var out, errOut bytes.Buffer
18+ env := Env{Stdout: &out, Stderr: &errOut, Version: "1.2.3"}
19+ code = exitCode(&errOut, Execute(context.Background(), env, args))
20+ return code, out.String(), errOut.String()
21+}
22+
23+func TestMergeDebug(t *testing.T) {
24+ if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" {
25+ t.Fatal(got)
26+ }
27+ if got := mergeDebug(nil, ""); got != "" {
28+ t.Fatal(got)
29+ }
30+ if got := mergeDebug([]string{"core"}, ""); got != "core" {
31+ t.Fatal(got)
32+ }
33+ if n := testing.AllocsPerRun(10, func() { _ = mergeDebug([]string{"core", "app"}, "render") }); n > 1 {
34+ t.Fatalf("%v allocations, want 1", n)
35+ }
36+}
37+
38+func TestParseSizes(t *testing.T) {
39+ sizes, err := parseSizes("1920x1080, 1280x720")
40+ if err != nil || len(sizes) != 2 || sizes[1] != [2]int{1280, 720} {
41+ t.Fatal(sizes, err)
42+ }
43+}
44+
45+// TestCommands checks the exit code and where each command writes: data
46+// and help on stdout, an error exactly once on stderr.
47+func TestCommands(t *testing.T) {
48+ dir := t.TempDir()
49+ cfg := dir + "/config"
50+ badCfg := dir + "/bad"
51+ stateFile := dir + "/state.json"
52+ pid := strconv.Itoa(os.Getpid())
53+ for path, data := range map[string]string{
54+ cfg: "",
55+ badCfg: "unknown.key = 1\n",
56+ stateFile: `{"output":"DP-1","outputs":[{"name":"DP-1","active":1,"count":1}],"windows":[{"id":1,"pid":` + pid + `,"output":"DP-1"}]}`,
57+ } {
58+ if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
59+ t.Fatal(err)
60+ }
61+ }
62+ t.Setenv("NEFERWL_STATE", stateFile)
63+ for _, tc := range []struct {
64+ args []string
65+ code int
66+ stdout, stderr string // substrings; "" means empty
67+ }{
68+ {[]string{"--help"}, 0, "neferwl state [output-of <pid>]", ""},
69+ {[]string{"state", "-h"}, 0, "usage: neferwl state [output-of <pid>]", ""},
70+ {[]string{"version"}, 0, "1.2.3\n", ""},
71+ {[]string{"validate-config", cfg}, 0, "ok: " + cfg + "\n", ""},
72+ {[]string{"validate-config", badCfg}, 1, "", "line 1"},
73+ {[]string{"state", "output-of", pid}, 0, `{"name":"DP-1","active":1,"count":1,"workspace":"","workspace_id":""}` + "\n", ""},
74+ {[]string{"state"}, 0, `"output":"DP-1"`, ""},
75+ {[]string{"--unknown"}, 2, "", "flag provided but not defined"},
76+ {[]string{"--backend=invalid"}, 2, "", "invalid backend"},
77+ {[]string{"--backend=drm", "--screenshot=" + dir}, 2, "", "--screenshot requires"},
78+ {[]string{"--backend=drm", "--headless-hdr"}, 2, "", "--headless-hdr requires"},
79+ {[]string{"--backend=drm", "--input=-"}, 2, "", "--input requires"},
80+ {[]string{"--backend=headless", "--screenshot=" + dir, "--screenshot-raw"}, 2, "", "--screenshot-raw requires"},
81+ {[]string{"--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2, "", "--screenshot-raw requires"},
82+ {[]string{"--size=1920"}, 2, "", "--size"},
83+ {[]string{"--timeout=-1s"}, 2, "", "negative timeout"},
84+ {[]string{"--debug=nope"}, 2, "", "--debug"},
85+ {[]string{"extra"}, 2, "", "unexpected arguments"},
86+ {[]string{"--config=/nonexistent/neferwl.conf"}, 1, "", "no such file"},
87+ {[]string{"validate-config", "/nonexistent/neferwl.conf"}, 1, "", "no such file"},
88+ {[]string{"validate-config", "a", "b"}, 2, "", "usage: neferwl validate-config [path]"},
89+ {[]string{"state", "output-of", "x"}, 2, "", "usage: neferwl state [output-of <pid>]"},
90+ {[]string{"state", "extra"}, 2, "", "unexpected arguments"},
91+ {[]string{"version", "extra"}, 2, "", "usage: neferwl version"},
92+ } {
93+ code, stdout, stderr := execute(t, tc.args...)
94+ if code != tc.code {
95+ t.Errorf("%v: exit %d, want %d (%s)", tc.args, code, tc.code, stderr)
96+ }
97+ if (tc.stdout == "") != (stdout == "") || !strings.Contains(stdout, tc.stdout) {
98+ t.Errorf("%v: stdout %q, want %q", tc.args, stdout, tc.stdout)
99+ }
100+ switch {
101+ case tc.stderr == "" && stderr != "":
102+ t.Errorf("%v: unexpected stderr %q", tc.args, stderr)
103+ case tc.stderr != "" && strings.Count(stderr, tc.stderr) != 1:
104+ t.Errorf("%v: stderr %q has %q %d times, want once", tc.args, stderr, tc.stderr, strings.Count(stderr, tc.stderr))
105+ }
106+ if code == 2 && strings.Count(stderr, "usage:") != 1 {
107+ t.Errorf("%v: stderr %q needs one usage line", tc.args, stderr)
108+ }
109+ }
110+}
111+
112+func TestExitCode(t *testing.T) {
113+ for _, tc := range []struct {
114+ name string
115+ err error
116+ code int
117+ print bool
118+ }{
119+ {"canceled", context.Canceled, 0, false},
120+ {"logged", loggedError{errors.New("boom")}, 1, false},
121+ {"plain", errors.New("boom"), 1, true},
122+ } {
123+ var b bytes.Buffer
124+ if code := exitCode(&b, tc.err); code != tc.code || (b.Len() > 0) != tc.print {
125+ t.Errorf("%s: exit %d printed %q", tc.name, code, b.String())
126+ }
127+ }
128+}
129+
130+func TestOpenScript(t *testing.T) {
131+ file := t.TempDir() + "/script"
132+ if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil {
133+ t.Fatal(err)
134+ }
135+ for _, tc := range []struct {
136+ name, path string
137+ check func(t *testing.T, script io.ReadCloser, err error)
138+ }{
139+ {"none", "", func(t *testing.T, script io.ReadCloser, err error) {
140+ if err != nil || script != nil {
141+ t.Fatalf("got %#v, %v; want a nil interface", script, err)
142+ }
143+ }},
144+ {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) {
145+ if err != nil || script != io.ReadCloser(os.Stdin) {
146+ t.Fatalf("got %#v, %v; want os.Stdin", script, err)
147+ }
148+ }},
149+ {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) {
150+ if err == nil || script != nil {
151+ t.Fatalf("got %#v, %v; want a nil interface and an error", script, err)
152+ }
153+ }},
154+ {"file", file, func(t *testing.T, script io.ReadCloser, err error) {
155+ if err != nil || script == nil {
156+ t.Fatalf("got %#v, %v", script, err)
157+ }
158+ if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" {
159+ t.Fatalf("read %q, %v", got, err)
160+ }
161+ }},
162+ } {
163+ t.Run(tc.name, func(t *testing.T) {
164+ script, closeScript, err := openScript(tc.path)
165+ if closeScript == nil {
166+ t.Fatal("nil close func")
167+ }
168+ tc.check(t, script, err)
169+ closeScript()
170+ if tc.path == "-" {
171+ if _, err := os.Stdin.Stat(); err != nil {
172+ t.Fatalf("stdin closed: %v", err)
173+ }
174+ }
175+ if tc.name == "file" {
176+ if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF {
177+ t.Fatalf("file left open: %v", err)
178+ }
179+ }
180+ })
181+ }
182+}
@@ -0,0 +1,182 @@
1+package cli
2+
3+import (
4+ "bytes"
5+ "context"
6+ "errors"
7+ "io"
8+ "os"
9+ "strconv"
10+ "strings"
11+ "testing"
12+)
13+
14+// execute runs args like Main and returns the exit code and output.
15+func execute(t *testing.T, args ...string) (code int, stdout, stderr string) {
16+ t.Helper()
17+ var out, errOut bytes.Buffer
18+ env := Env{Stdout: &out, Stderr: &errOut, Version: "1.2.3"}
19+ code = exitCode(&errOut, Execute(context.Background(), env, args))
20+ return code, out.String(), errOut.String()
21+}
22+
23+func TestMergeDebug(t *testing.T) {
24+ if got := mergeDebug([]string{"core", "app"}, "render,core"); got != "core,app,render,core" {
25+ t.Fatal(got)
26+ }
27+ if got := mergeDebug(nil, ""); got != "" {
28+ t.Fatal(got)
29+ }
30+ if got := mergeDebug([]string{"core"}, ""); got != "core" {
31+ t.Fatal(got)
32+ }
33+ if n := testing.AllocsPerRun(10, func() { _ = mergeDebug([]string{"core", "app"}, "render") }); n > 1 {
34+ t.Fatalf("%v allocations, want 1", n)
35+ }
36+}
37+
38+func TestParseSizes(t *testing.T) {
39+ sizes, err := parseSizes("1920x1080, 1280x720")
40+ if err != nil || len(sizes) != 2 || sizes[1] != [2]int{1280, 720} {
41+ t.Fatal(sizes, err)
42+ }
43+}
44+
45+// TestCommands checks the exit code and where each command writes: data
46+// and help on stdout, an error exactly once on stderr.
47+func TestCommands(t *testing.T) {
48+ dir := t.TempDir()
49+ cfg := dir + "/config"
50+ badCfg := dir + "/bad"
51+ stateFile := dir + "/state.json"
52+ pid := strconv.Itoa(os.Getpid())
53+ for path, data := range map[string]string{
54+ cfg: "",
55+ badCfg: "unknown.key = 1\n",
56+ stateFile: `{"output":"DP-1","outputs":[{"name":"DP-1","active":1,"count":1}],"windows":[{"id":1,"pid":` + pid + `,"output":"DP-1"}]}`,
57+ } {
58+ if err := os.WriteFile(path, []byte(data), 0o600); err != nil {
59+ t.Fatal(err)
60+ }
61+ }
62+ t.Setenv("NEFERWL_STATE", stateFile)
63+ for _, tc := range []struct {
64+ args []string
65+ code int
66+ stdout, stderr string // substrings; "" means empty
67+ }{
68+ {[]string{"--help"}, 0, "neferwl state [output-of <pid>]", ""},
69+ {[]string{"state", "-h"}, 0, "usage: neferwl state [output-of <pid>]", ""},
70+ {[]string{"version"}, 0, "1.2.3\n", ""},
71+ {[]string{"validate-config", cfg}, 0, "ok: " + cfg + "\n", ""},
72+ {[]string{"validate-config", badCfg}, 1, "", "line 1"},
73+ {[]string{"state", "output-of", pid}, 0, `{"name":"DP-1","active":1,"count":1,"workspace":"","workspace_id":""}` + "\n", ""},
74+ {[]string{"state"}, 0, `"output":"DP-1"`, ""},
75+ {[]string{"--unknown"}, 2, "", "flag provided but not defined"},
76+ {[]string{"--backend=invalid"}, 2, "", "invalid backend"},
77+ {[]string{"--backend=drm", "--screenshot=" + dir}, 2, "", "--screenshot requires"},
78+ {[]string{"--backend=drm", "--headless-hdr"}, 2, "", "--headless-hdr requires"},
79+ {[]string{"--backend=drm", "--input=-"}, 2, "", "--input requires"},
80+ {[]string{"--backend=headless", "--screenshot=" + dir, "--screenshot-raw"}, 2, "", "--screenshot-raw requires"},
81+ {[]string{"--backend=headless", "--headless-hdr", "--screenshot-raw"}, 2, "", "--screenshot-raw requires"},
82+ {[]string{"--size=1920"}, 2, "", "--size"},
83+ {[]string{"--timeout=-1s"}, 2, "", "negative timeout"},
84+ {[]string{"--debug=nope"}, 2, "", "--debug"},
85+ {[]string{"extra"}, 2, "", "unexpected arguments"},
86+ {[]string{"--config=/nonexistent/neferwl.conf"}, 1, "", "no such file"},
87+ {[]string{"validate-config", "/nonexistent/neferwl.conf"}, 1, "", "no such file"},
88+ {[]string{"validate-config", "a", "b"}, 2, "", "usage: neferwl validate-config [path]"},
89+ {[]string{"state", "output-of", "x"}, 2, "", "usage: neferwl state [output-of <pid>]"},
90+ {[]string{"state", "extra"}, 2, "", "unexpected arguments"},
91+ {[]string{"version", "extra"}, 2, "", "usage: neferwl version"},
92+ } {
93+ code, stdout, stderr := execute(t, tc.args...)
94+ if code != tc.code {
95+ t.Errorf("%v: exit %d, want %d (%s)", tc.args, code, tc.code, stderr)
96+ }
97+ if (tc.stdout == "") != (stdout == "") || !strings.Contains(stdout, tc.stdout) {
98+ t.Errorf("%v: stdout %q, want %q", tc.args, stdout, tc.stdout)
99+ }
100+ switch {
101+ case tc.stderr == "" && stderr != "":
102+ t.Errorf("%v: unexpected stderr %q", tc.args, stderr)
103+ case tc.stderr != "" && strings.Count(stderr, tc.stderr) != 1:
104+ t.Errorf("%v: stderr %q has %q %d times, want once", tc.args, stderr, tc.stderr, strings.Count(stderr, tc.stderr))
105+ }
106+ if code == 2 && strings.Count(stderr, "usage:") != 1 {
107+ t.Errorf("%v: stderr %q needs one usage line", tc.args, stderr)
108+ }
109+ }
110+}
111+
112+func TestExitCode(t *testing.T) {
113+ for _, tc := range []struct {
114+ name string
115+ err error
116+ code int
117+ print bool
118+ }{
119+ {"canceled", context.Canceled, 0, false},
120+ {"logged", loggedError{errors.New("boom")}, 1, false},
121+ {"plain", errors.New("boom"), 1, true},
122+ } {
123+ var b bytes.Buffer
124+ if code := exitCode(&b, tc.err); code != tc.code || (b.Len() > 0) != tc.print {
125+ t.Errorf("%s: exit %d printed %q", tc.name, code, b.String())
126+ }
127+ }
128+}
129+
130+func TestOpenScript(t *testing.T) {
131+ file := t.TempDir() + "/script"
132+ if err := os.WriteFile(file, []byte("quit\n"), 0o600); err != nil {
133+ t.Fatal(err)
134+ }
135+ for _, tc := range []struct {
136+ name, path string
137+ check func(t *testing.T, script io.ReadCloser, err error)
138+ }{
139+ {"none", "", func(t *testing.T, script io.ReadCloser, err error) {
140+ if err != nil || script != nil {
141+ t.Fatalf("got %#v, %v; want a nil interface", script, err)
142+ }
143+ }},
144+ {"stdin", "-", func(t *testing.T, script io.ReadCloser, err error) {
145+ if err != nil || script != io.ReadCloser(os.Stdin) {
146+ t.Fatalf("got %#v, %v; want os.Stdin", script, err)
147+ }
148+ }},
149+ {"missing", "/nonexistent/script", func(t *testing.T, script io.ReadCloser, err error) {
150+ if err == nil || script != nil {
151+ t.Fatalf("got %#v, %v; want a nil interface and an error", script, err)
152+ }
153+ }},
154+ {"file", file, func(t *testing.T, script io.ReadCloser, err error) {
155+ if err != nil || script == nil {
156+ t.Fatalf("got %#v, %v", script, err)
157+ }
158+ if got, err := io.ReadAll(script); err != nil || string(got) != "quit\n" {
159+ t.Fatalf("read %q, %v", got, err)
160+ }
161+ }},
162+ } {
163+ t.Run(tc.name, func(t *testing.T) {
164+ script, closeScript, err := openScript(tc.path)
165+ if closeScript == nil {
166+ t.Fatal("nil close func")
167+ }
168+ tc.check(t, script, err)
169+ closeScript()
170+ if tc.path == "-" {
171+ if _, err := os.Stdin.Stat(); err != nil {
172+ t.Fatalf("stdin closed: %v", err)
173+ }
174+ }
175+ if tc.name == "file" {
176+ if _, err := script.Read(make([]byte, 1)); err == nil || err == io.EOF {
177+ t.Fatalf("file left open: %v", err)
178+ }
179+ }
180+ })
181+ }
182+}
A internal/cli/commands.go +131 -0
@@ -0,0 +1,131 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "flag"
7+ "fmt"
8+ "os"
9+ "runtime/debug"
10+ "strconv"
11+
12+ "github.com/bnema/neferwl/internal/adapters/config"
13+ "github.com/bnema/neferwl/internal/adapters/statefile"
14+ "github.com/bnema/neferwl/internal/ports"
15+)
16+
17+var validateConfigCmd = Command{
18+ Name: "validate-config",
19+ Args: "[path]",
20+ Summary: "Check a config file without starting the compositor.",
21+ Setup: func(*flag.FlagSet) Runner {
22+ return func(_ context.Context, env Env, args []string) error {
23+ if len(args) > 1 {
24+ return usagef("expected at most one path, got %d", len(args))
25+ }
26+ path := ""
27+ if len(args) == 1 {
28+ path = args[0]
29+ }
30+ path, _, warnings, err := loadConfig(path)
31+ if err != nil {
32+ return err
33+ }
34+ for _, w := range warnings {
35+ fmt.Fprintf(env.Stderr, "%s:%s\n", path, w)
36+ }
37+ if len(warnings) > 0 {
38+ return fmt.Errorf("%d warning(s)", len(warnings))
39+ }
40+ fmt.Fprintf(env.Stdout, "ok: %s\n", path)
41+ return nil
42+ }
43+ },
44+}
45+
46+// loadConfig reads the config at path, or the XDG default when path is
47+// empty; a missing default file gives the defaults. It returns the path read.
48+func loadConfig(path string) (string, ports.Config, []config.Warning, error) {
49+ if path == "" {
50+ cfg, warnings, err := config.LoadDefault()
51+ return config.DefaultPath(), cfg, warnings, err
52+ }
53+ cfg, warnings, err := config.Load(path)
54+ return path, cfg, warnings, err
55+}
56+
57+// stateCmd reads the file named by $NEFERWL_STATE, else the one of
58+// $WAYLAND_DISPLAY.
59+var stateCmd = Command{
60+ Name: "state",
61+ Args: "[output-of <pid>]",
62+ Summary: "Print the session state, or the output showing a process's window, as JSON.",
63+ Setup: func(*flag.FlagSet) Runner {
64+ return func(_ context.Context, env Env, args []string) error {
65+ pid := 0
66+ switch {
67+ case len(args) == 0:
68+ case len(args) == 2 && args[0] == "output-of":
69+ n, err := strconv.Atoi(args[1])
70+ if err != nil || n <= 0 {
71+ return usagef("invalid pid %q", args[1])
72+ }
73+ pid = n
74+ default:
75+ return usagef("unexpected arguments: %v", args)
76+ }
77+ path := os.Getenv(statefile.Env)
78+ if path == "" {
79+ var err error
80+ if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil {
81+ return err
82+ }
83+ }
84+ st, err := statefile.Read(path)
85+ if err != nil {
86+ return err
87+ }
88+ var out any = st
89+ if pid != 0 {
90+ if out, err = statefile.OutputOf(st, pid); err != nil {
91+ return err
92+ }
93+ }
94+ data, err := json.Marshal(out)
95+ if err != nil {
96+ return err
97+ }
98+ _, err = env.Stdout.Write(append(data, '\n'))
99+ return err
100+ }
101+ },
102+}
103+
104+var versionCmd = Command{
105+ Name: "version",
106+ Summary: "Print the neferwl version.",
107+ Setup: func(*flag.FlagSet) Runner {
108+ return func(_ context.Context, env Env, args []string) error {
109+ if len(args) != 0 {
110+ return usagef("unexpected arguments: %v", args)
111+ }
112+ fmt.Fprintln(env.Stdout, buildVersion(env.Version))
113+ return nil
114+ }
115+ },
116+}
117+
118+// buildVersion prefers the packaged version, then the module version.
119+func buildVersion(packaged string) string {
120+ if packaged != "" {
121+ return packaged
122+ }
123+ info, ok := debug.ReadBuildInfo()
124+ if !ok {
125+ return "unknown"
126+ }
127+ if info.Main.Version == "" {
128+ return "(devel)"
129+ }
130+ return info.Main.Version
131+}
@@ -0,0 +1,131 @@
1+package cli
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "flag"
7+ "fmt"
8+ "os"
9+ "runtime/debug"
10+ "strconv"
11+
12+ "github.com/bnema/neferwl/internal/adapters/config"
13+ "github.com/bnema/neferwl/internal/adapters/statefile"
14+ "github.com/bnema/neferwl/internal/ports"
15+)
16+
17+var validateConfigCmd = Command{
18+ Name: "validate-config",
19+ Args: "[path]",
20+ Summary: "Check a config file without starting the compositor.",
21+ Setup: func(*flag.FlagSet) Runner {
22+ return func(_ context.Context, env Env, args []string) error {
23+ if len(args) > 1 {
24+ return usagef("expected at most one path, got %d", len(args))
25+ }
26+ path := ""
27+ if len(args) == 1 {
28+ path = args[0]
29+ }
30+ path, _, warnings, err := loadConfig(path)
31+ if err != nil {
32+ return err
33+ }
34+ for _, w := range warnings {
35+ fmt.Fprintf(env.Stderr, "%s:%s\n", path, w)
36+ }
37+ if len(warnings) > 0 {
38+ return fmt.Errorf("%d warning(s)", len(warnings))
39+ }
40+ fmt.Fprintf(env.Stdout, "ok: %s\n", path)
41+ return nil
42+ }
43+ },
44+}
45+
46+// loadConfig reads the config at path, or the XDG default when path is
47+// empty; a missing default file gives the defaults. It returns the path read.
48+func loadConfig(path string) (string, ports.Config, []config.Warning, error) {
49+ if path == "" {
50+ cfg, warnings, err := config.LoadDefault()
51+ return config.DefaultPath(), cfg, warnings, err
52+ }
53+ cfg, warnings, err := config.Load(path)
54+ return path, cfg, warnings, err
55+}
56+
57+// stateCmd reads the file named by $NEFERWL_STATE, else the one of
58+// $WAYLAND_DISPLAY.
59+var stateCmd = Command{
60+ Name: "state",
61+ Args: "[output-of <pid>]",
62+ Summary: "Print the session state, or the output showing a process's window, as JSON.",
63+ Setup: func(*flag.FlagSet) Runner {
64+ return func(_ context.Context, env Env, args []string) error {
65+ pid := 0
66+ switch {
67+ case len(args) == 0:
68+ case len(args) == 2 && args[0] == "output-of":
69+ n, err := strconv.Atoi(args[1])
70+ if err != nil || n <= 0 {
71+ return usagef("invalid pid %q", args[1])
72+ }
73+ pid = n
74+ default:
75+ return usagef("unexpected arguments: %v", args)
76+ }
77+ path := os.Getenv(statefile.Env)
78+ if path == "" {
79+ var err error
80+ if path, err = statefile.Path(os.Getenv("XDG_RUNTIME_DIR"), os.Getenv("WAYLAND_DISPLAY")); err != nil {
81+ return err
82+ }
83+ }
84+ st, err := statefile.Read(path)
85+ if err != nil {
86+ return err
87+ }
88+ var out any = st
89+ if pid != 0 {
90+ if out, err = statefile.OutputOf(st, pid); err != nil {
91+ return err
92+ }
93+ }
94+ data, err := json.Marshal(out)
95+ if err != nil {
96+ return err
97+ }
98+ _, err = env.Stdout.Write(append(data, '\n'))
99+ return err
100+ }
101+ },
102+}
103+
104+var versionCmd = Command{
105+ Name: "version",
106+ Summary: "Print the neferwl version.",
107+ Setup: func(*flag.FlagSet) Runner {
108+ return func(_ context.Context, env Env, args []string) error {
109+ if len(args) != 0 {
110+ return usagef("unexpected arguments: %v", args)
111+ }
112+ fmt.Fprintln(env.Stdout, buildVersion(env.Version))
113+ return nil
114+ }
115+ },
116+}
117+
118+// buildVersion prefers the packaged version, then the module version.
119+func buildVersion(packaged string) string {
120+ if packaged != "" {
121+ return packaged
122+ }
123+ info, ok := debug.ReadBuildInfo()
124+ if !ok {
125+ return "unknown"
126+ }
127+ if info.Main.Version == "" {
128+ return "(devel)"
129+ }
130+ return info.Main.Version
131+}
A internal/cli/run.go +267 -0
@@ -0,0 +1,267 @@
1+package cli
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "net"
10+ "net/http"
11+ "net/http/pprof"
12+ "os"
13+ "os/signal"
14+ "runtime/debug"
15+ "strings"
16+ "syscall"
17+ "time"
18+
19+ "github.com/bnema/neferwl/internal/adapters/config"
20+ "github.com/bnema/neferwl/internal/app"
21+ "github.com/bnema/neferwl/internal/logging"
22+ "golang.org/x/term"
23+)
24+
25+// runFlags are the root command flags.
26+type runFlags struct {
27+ backend, screenshot, size, input, debug, config, pprof string
28+ noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool
29+ timeout time.Duration
30+}
31+
32+func (f *runFlags) register(fs *flag.FlagSet) {
33+ fs.StringVar(&f.backend, "backend", "drm", "drm or headless")
34+ fs.StringVar(&f.screenshot, "screenshot", "", "write PNG frames to directory")
35+ fs.StringVar(&f.size, "size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
36+ fs.StringVar(&f.input, "input", "", "headless input script path (- for stdin)")
37+ fs.BoolVar(&f.noTerminal, "no-terminal", false, "skip initial terminal")
38+ fs.BoolVar(&f.noXwayland, "no-xwayland", false, "no X11 display for X11 apps")
39+ fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
40+ fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
41+ fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")
42+ fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
43+ fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
44+ fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45+ fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
46+}
47+
48+// validate checks the flag combinations and parses --size.
49+func (f *runFlags) validate(args []string) ([][2]int, error) {
50+ headless := f.backend == "headless"
51+ switch {
52+ case f.backend != "drm" && !headless:
53+ return nil, usagef("invalid backend %q", f.backend)
54+ case f.headlessHDR && !headless:
55+ return nil, usagef("--headless-hdr requires --backend=headless")
56+ case f.screenshot != "" && !headless:
57+ return nil, usagef("--screenshot requires --backend=headless")
58+ case f.screenshotRaw && (f.screenshot == "" || !f.headlessHDR):
59+ return nil, usagef("--screenshot-raw requires --screenshot and --headless-hdr")
60+ case f.input != "" && !headless:
61+ return nil, usagef("--input requires --backend=headless")
62+ case f.timeout < 0:
63+ return nil, usagef("negative timeout")
64+ case len(args) != 0:
65+ return nil, usagef("unexpected arguments: %v", args)
66+ }
67+ // The config file checks its own log.debug names.
68+ if _, err := logging.ParseDebug(f.debug); err != nil {
69+ return nil, usageError{fmt.Errorf("--debug: %w", err)}
70+ }
71+ return parseSizes(f.size)
72+}
73+
74+func parseSizes(value string) ([][2]int, error) {
75+ sizes := make([][2]int, 0, strings.Count(value, ",")+1)
76+ for s := range strings.SplitSeq(value, ",") {
77+ w, h, _, err := config.ParseMode(strings.TrimSpace(s))
78+ if err != nil {
79+ return nil, usagef("--size: %w", err)
80+ }
81+ if w == 0 {
82+ return nil, usagef("--size: need WxH, got %q", s)
83+ }
84+ sizes = append(sizes, [2]int{w, h})
85+ }
86+ return sizes, nil
87+}
88+
89+func setupRun(fs *flag.FlagSet) Runner {
90+ var f runFlags
91+ f.register(fs)
92+ return func(ctx context.Context, _ Env, args []string) error {
93+ sizes, err := f.validate(args)
94+ if err != nil {
95+ return err
96+ }
97+ return f.run(ctx, sizes)
98+ }
99+}
100+
101+// run starts the compositor once the flags are valid. SIGINT and SIGTERM
102+// stop it; other commands keep the default signal behaviour.
103+func (f *runFlags) run(ctx context.Context, sizes [][2]int) error {
104+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
105+ defer stop()
106+ if f.screenshot != "" {
107+ if err := os.MkdirAll(f.screenshot, 0o755); err != nil {
108+ return err
109+ }
110+ }
111+ path, cfg, warnings, err := loadConfig(f.config)
112+ if err != nil {
113+ return err
114+ }
115+ script, closeScript, err := openScript(f.input)
116+ if err != nil {
117+ return err
118+ }
119+ // app.Run closes the script on shutdown; this covers the returns before it.
120+ defer closeScript()
121+ tuneGC()
122+ ctx, closeLog, err := logging.Open(ctx, f.backend, cfg.Log.Level, mergeDebug(cfg.Log.Debug, f.debug))
123+ if err != nil {
124+ return err
125+ }
126+ defer closeLog()
127+ log := logging.For(ctx, "app")
128+ defer func() {
129+ if p := recover(); p != nil {
130+ log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
131+ _ = closeLog()
132+ panic(p)
133+ }
134+ }()
135+ if f.pprof != "" {
136+ if err := servePprof(ctx, f.pprof); err != nil {
137+ return err
138+ }
139+ log.Info().Str("addr", f.pprof).Msg("pprof")
140+ }
141+ start := time.Now()
142+ log.Info().Strs("args", os.Args[1:]).Str("backend", f.backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
143+ configLog := logging.For(ctx, "config")
144+ configLog.Info().Str("path", path).Msg("loaded config")
145+ for _, w := range warnings {
146+ configLog.Warn().Int("line", w.Line).Msg(w.Msg)
147+ }
148+ err = app.Run(ctx, app.Options{
149+ Backend: f.backend,
150+ Config: cfg,
151+ ConfigPath: path,
152+ Timeout: f.timeout,
153+ NoTerminal: f.noTerminal,
154+ NoXwayland: f.noXwayland,
155+ HeadlessHDR: f.headlessHDR,
156+ Session: f.session,
157+ ScreenshotDir: f.screenshot,
158+ ScreenshotRaw: f.screenshotRaw,
159+ Sizes: sizes,
160+ Script: script,
161+ })
162+ // SIGINT and SIGTERM cancel the context and are clean exits.
163+ if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
164+ err = nil
165+ }
166+ reason := "clean"
167+ switch {
168+ case err != nil:
169+ reason = "error"
170+ case ctx.Err() != nil:
171+ reason = "signal"
172+ case f.timeout > 0 && time.Since(start) >= f.timeout:
173+ reason = "timeout"
174+ }
175+ log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
176+ // On a terminal the run log already went to stderr (logging.Open).
177+ if err != nil && term.IsTerminal(int(os.Stderr.Fd())) {
178+ return loggedError{err}
179+ }
180+ return err
181+}
182+
183+// loggedError is an error the run log already printed on stderr; exitCode
184+// does not print it again.
185+type loggedError struct{ error }
186+
187+func (e loggedError) Unwrap() error { return e.error }
188+
189+// mergeDebug joins the configured debug categories and the --debug value
190+// in at most one allocation.
191+func mergeDebug(configured []string, cli string) string {
192+ switch {
193+ case len(configured) == 0:
194+ return cli
195+ case len(configured) == 1 && cli == "":
196+ return configured[0]
197+ }
198+ n := len(cli) + len(configured)
199+ for _, c := range configured {
200+ n += len(c)
201+ }
202+ var b strings.Builder
203+ b.Grow(n)
204+ for i, c := range configured {
205+ if i > 0 {
206+ b.WriteByte(',')
207+ }
208+ b.WriteString(c)
209+ }
210+ if cli != "" {
211+ b.WriteByte(',')
212+ b.WriteString(cli)
213+ }
214+ return b.String()
215+}
216+
217+// openScript opens the --input script. It returns a nil interface, not a
218+// typed-nil *os.File, when no script was requested: app.Run tests the
219+// interface against nil to decide whether to read a script. closeScript
220+// closes what openScript opened; it does nothing for stdin and for no script.
221+func openScript(path string) (script io.ReadCloser, closeScript func(), err error) {
222+ switch path {
223+ case "":
224+ return nil, func() {}, nil
225+ case "-":
226+ return os.Stdin, func() {}, nil
227+ }
228+ f, err := os.Open(path)
229+ if err != nil {
230+ return nil, func() {}, err
231+ }
232+ return f, func() { _ = f.Close() }, nil
233+}
234+
235+// tuneGC bounds the heap of a process that runs for days. The Go live heap
236+// is small (tens of MB; buffers live in GPU and shared memory), so the
237+// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
238+// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
239+// more heap. A soft limit makes the collector work harder before the
240+// process gets large. GOGC and GOMEMLIMIT set in the environment win.
241+func tuneGC() {
242+ if os.Getenv("GOMEMLIMIT") == "" {
243+ debug.SetMemoryLimit(256 << 20)
244+ }
245+}
246+
247+// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
248+// done. It binds before returning so a bad address fails at startup.
249+func servePprof(ctx context.Context, addr string) error {
250+ ln, err := net.Listen("tcp", addr)
251+ if err != nil {
252+ return fmt.Errorf("--pprof: %w", err)
253+ }
254+ mux := http.NewServeMux()
255+ mux.HandleFunc("/debug/pprof/", pprof.Index)
256+ mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
257+ mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
258+ mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
259+ mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
260+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
261+ go func() { _ = srv.Serve(ln) }()
262+ go func() {
263+ <-ctx.Done()
264+ _ = srv.Close()
265+ }()
266+ return nil
267+}
@@ -0,0 +1,267 @@
1+package cli
2+
3+import (
4+ "context"
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "net"
10+ "net/http"
11+ "net/http/pprof"
12+ "os"
13+ "os/signal"
14+ "runtime/debug"
15+ "strings"
16+ "syscall"
17+ "time"
18+
19+ "github.com/bnema/neferwl/internal/adapters/config"
20+ "github.com/bnema/neferwl/internal/app"
21+ "github.com/bnema/neferwl/internal/logging"
22+ "golang.org/x/term"
23+)
24+
25+// runFlags are the root command flags.
26+type runFlags struct {
27+ backend, screenshot, size, input, debug, config, pprof string
28+ noTerminal, noXwayland, headlessHDR, screenshotRaw, session bool
29+ timeout time.Duration
30+}
31+
32+func (f *runFlags) register(fs *flag.FlagSet) {
33+ fs.StringVar(&f.backend, "backend", "drm", "drm or headless")
34+ fs.StringVar(&f.screenshot, "screenshot", "", "write PNG frames to directory")
35+ fs.StringVar(&f.size, "size", "1920x1080", "headless output sizes WxH, comma-separated for several outputs")
36+ fs.StringVar(&f.input, "input", "", "headless input script path (- for stdin)")
37+ fs.BoolVar(&f.noTerminal, "no-terminal", false, "skip initial terminal")
38+ fs.BoolVar(&f.noXwayland, "no-xwayland", false, "no X11 display for X11 apps")
39+ fs.BoolVar(&f.headlessHDR, "headless-hdr", false, "test-only: report HDR on virtual outputs; fall back if Vulkan HDR unavailable")
40+ fs.BoolVar(&f.screenshotRaw, "screenshot-raw", false, "test-only: also write latest-pq.png, the raw PQ codes of an HDR output")
41+ fs.DurationVar(&f.timeout, "timeout", 0, "duration before exit (0 disables timeout)")
42+ fs.StringVar(&f.debug, "debug", "", "debug components (comma-separated or all; all leaves out drm-flip, input-motion and input-keys, which log every flip, pointer motion or key)")
43+ fs.StringVar(&f.config, "config", "", "config path (empty uses XDG default)")
44+ fs.BoolVar(&f.session, "session", false, "run as the login session: share WAYLAND_DISPLAY and DISPLAY with systemd and D-Bus user services; notify systemd when ready")
45+ fs.StringVar(&f.pprof, "pprof", "", "serve Go runtime profiles (heap, CPU, trace) on this address, e.g. localhost:6060")
46+}
47+
48+// validate checks the flag combinations and parses --size.
49+func (f *runFlags) validate(args []string) ([][2]int, error) {
50+ headless := f.backend == "headless"
51+ switch {
52+ case f.backend != "drm" && !headless:
53+ return nil, usagef("invalid backend %q", f.backend)
54+ case f.headlessHDR && !headless:
55+ return nil, usagef("--headless-hdr requires --backend=headless")
56+ case f.screenshot != "" && !headless:
57+ return nil, usagef("--screenshot requires --backend=headless")
58+ case f.screenshotRaw && (f.screenshot == "" || !f.headlessHDR):
59+ return nil, usagef("--screenshot-raw requires --screenshot and --headless-hdr")
60+ case f.input != "" && !headless:
61+ return nil, usagef("--input requires --backend=headless")
62+ case f.timeout < 0:
63+ return nil, usagef("negative timeout")
64+ case len(args) != 0:
65+ return nil, usagef("unexpected arguments: %v", args)
66+ }
67+ // The config file checks its own log.debug names.
68+ if _, err := logging.ParseDebug(f.debug); err != nil {
69+ return nil, usageError{fmt.Errorf("--debug: %w", err)}
70+ }
71+ return parseSizes(f.size)
72+}
73+
74+func parseSizes(value string) ([][2]int, error) {
75+ sizes := make([][2]int, 0, strings.Count(value, ",")+1)
76+ for s := range strings.SplitSeq(value, ",") {
77+ w, h, _, err := config.ParseMode(strings.TrimSpace(s))
78+ if err != nil {
79+ return nil, usagef("--size: %w", err)
80+ }
81+ if w == 0 {
82+ return nil, usagef("--size: need WxH, got %q", s)
83+ }
84+ sizes = append(sizes, [2]int{w, h})
85+ }
86+ return sizes, nil
87+}
88+
89+func setupRun(fs *flag.FlagSet) Runner {
90+ var f runFlags
91+ f.register(fs)
92+ return func(ctx context.Context, _ Env, args []string) error {
93+ sizes, err := f.validate(args)
94+ if err != nil {
95+ return err
96+ }
97+ return f.run(ctx, sizes)
98+ }
99+}
100+
101+// run starts the compositor once the flags are valid. SIGINT and SIGTERM
102+// stop it; other commands keep the default signal behaviour.
103+func (f *runFlags) run(ctx context.Context, sizes [][2]int) error {
104+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
105+ defer stop()
106+ if f.screenshot != "" {
107+ if err := os.MkdirAll(f.screenshot, 0o755); err != nil {
108+ return err
109+ }
110+ }
111+ path, cfg, warnings, err := loadConfig(f.config)
112+ if err != nil {
113+ return err
114+ }
115+ script, closeScript, err := openScript(f.input)
116+ if err != nil {
117+ return err
118+ }
119+ // app.Run closes the script on shutdown; this covers the returns before it.
120+ defer closeScript()
121+ tuneGC()
122+ ctx, closeLog, err := logging.Open(ctx, f.backend, cfg.Log.Level, mergeDebug(cfg.Log.Debug, f.debug))
123+ if err != nil {
124+ return err
125+ }
126+ defer closeLog()
127+ log := logging.For(ctx, "app")
128+ defer func() {
129+ if p := recover(); p != nil {
130+ log.Error().Interface("panic", p).Str("stack", string(debug.Stack())).Msg("exit")
131+ _ = closeLog()
132+ panic(p)
133+ }
134+ }()
135+ if f.pprof != "" {
136+ if err := servePprof(ctx, f.pprof); err != nil {
137+ return err
138+ }
139+ log.Info().Str("addr", f.pprof).Msg("pprof")
140+ }
141+ start := time.Now()
142+ log.Info().Strs("args", os.Args[1:]).Str("backend", f.backend).Str("tty", os.Getenv("XDG_VTNR")).Str("session_type", os.Getenv("XDG_SESSION_TYPE")).Msg("run")
143+ configLog := logging.For(ctx, "config")
144+ configLog.Info().Str("path", path).Msg("loaded config")
145+ for _, w := range warnings {
146+ configLog.Warn().Int("line", w.Line).Msg(w.Msg)
147+ }
148+ err = app.Run(ctx, app.Options{
149+ Backend: f.backend,
150+ Config: cfg,
151+ ConfigPath: path,
152+ Timeout: f.timeout,
153+ NoTerminal: f.noTerminal,
154+ NoXwayland: f.noXwayland,
155+ HeadlessHDR: f.headlessHDR,
156+ Session: f.session,
157+ ScreenshotDir: f.screenshot,
158+ ScreenshotRaw: f.screenshotRaw,
159+ Sizes: sizes,
160+ Script: script,
161+ })
162+ // SIGINT and SIGTERM cancel the context and are clean exits.
163+ if err != nil && ctx.Err() != nil && errors.Is(err, context.Canceled) {
164+ err = nil
165+ }
166+ reason := "clean"
167+ switch {
168+ case err != nil:
169+ reason = "error"
170+ case ctx.Err() != nil:
171+ reason = "signal"
172+ case f.timeout > 0 && time.Since(start) >= f.timeout:
173+ reason = "timeout"
174+ }
175+ log.Info().Str("reason", reason).AnErr("error", err).Dur("uptime", time.Since(start)).Msg("exit")
176+ // On a terminal the run log already went to stderr (logging.Open).
177+ if err != nil && term.IsTerminal(int(os.Stderr.Fd())) {
178+ return loggedError{err}
179+ }
180+ return err
181+}
182+
183+// loggedError is an error the run log already printed on stderr; exitCode
184+// does not print it again.
185+type loggedError struct{ error }
186+
187+func (e loggedError) Unwrap() error { return e.error }
188+
189+// mergeDebug joins the configured debug categories and the --debug value
190+// in at most one allocation.
191+func mergeDebug(configured []string, cli string) string {
192+ switch {
193+ case len(configured) == 0:
194+ return cli
195+ case len(configured) == 1 && cli == "":
196+ return configured[0]
197+ }
198+ n := len(cli) + len(configured)
199+ for _, c := range configured {
200+ n += len(c)
201+ }
202+ var b strings.Builder
203+ b.Grow(n)
204+ for i, c := range configured {
205+ if i > 0 {
206+ b.WriteByte(',')
207+ }
208+ b.WriteString(c)
209+ }
210+ if cli != "" {
211+ b.WriteByte(',')
212+ b.WriteString(cli)
213+ }
214+ return b.String()
215+}
216+
217+// openScript opens the --input script. It returns a nil interface, not a
218+// typed-nil *os.File, when no script was requested: app.Run tests the
219+// interface against nil to decide whether to read a script. closeScript
220+// closes what openScript opened; it does nothing for stdin and for no script.
221+func openScript(path string) (script io.ReadCloser, closeScript func(), err error) {
222+ switch path {
223+ case "":
224+ return nil, func() {}, nil
225+ case "-":
226+ return os.Stdin, func() {}, nil
227+ }
228+ f, err := os.Open(path)
229+ if err != nil {
230+ return nil, func() {}, err
231+ }
232+ return f, func() { _ = f.Close() }, nil
233+}
234+
235+// tuneGC bounds the heap of a process that runs for days. The Go live heap
236+// is small (tens of MB; buffers live in GPU and shared memory), so the
237+// default GOGC=100 is kept: on the tiled playback test, GOGC=50 ran 13
238+// collections for 91 ms of GC CPU where GOGC=100 ran 2 for 3 ms, for 2 MB
239+// more heap. A soft limit makes the collector work harder before the
240+// process gets large. GOGC and GOMEMLIMIT set in the environment win.
241+func tuneGC() {
242+ if os.Getenv("GOMEMLIMIT") == "" {
243+ debug.SetMemoryLimit(256 << 20)
244+ }
245+}
246+
247+// servePprof exposes the runtime profiles under /debug/pprof/ until ctx is
248+// done. It binds before returning so a bad address fails at startup.
249+func servePprof(ctx context.Context, addr string) error {
250+ ln, err := net.Listen("tcp", addr)
251+ if err != nil {
252+ return fmt.Errorf("--pprof: %w", err)
253+ }
254+ mux := http.NewServeMux()
255+ mux.HandleFunc("/debug/pprof/", pprof.Index)
256+ mux.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
257+ mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
258+ mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
259+ mux.HandleFunc("/debug/pprof/trace", pprof.Trace)
260+ srv := &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second}
261+ go func() { _ = srv.Serve(ln) }()
262+ go func() {
263+ <-ctx.Done()
264+ _ = srv.Close()
265+ }()
266+ return nil
267+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 23

Tags 10

↑↓ moveenter openesc close