detached a92355b

feat(app): wire headless wayland server, core and launcher

commit
a92355b31984129df9e3469249df75d8970d2897
author
brice · · 2026-09-23 12:22 UTC
parent
812b032
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +266 -27
M .hexcheck.yaml +3 -0
@@ -19,3 +19,6 @@ ruleSettings:
1919 missing-generated-mock-for-port:
2020 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
2121 excludePaths: [internal/ports/messages.go]
22+ suspicious-business-logic-in-adapter:
23+ # app is the composition root; starting core and wiring channels is its job.
24+ excludePaths: [internal/app/app.go]
@@ -19,3 +19,6 @@ ruleSettings:
19 missing-generated-mock-for-port:19 missing-generated-mock-for-port:
20 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).20 # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005).
21 excludePaths: [internal/ports/messages.go]21 excludePaths: [internal/ports/messages.go]
22+ suspicious-business-logic-in-adapter:
23+ # app is the composition root; starting core and wiring channels is its job.
24+ excludePaths: [internal/app/app.go]
M README.md +5 -0
@@ -22,3 +22,8 @@ gaps = 8
2222 See `examples/config.toml` for all defaults.
2323
2424 Building requires a sibling checkout of purego-libwayland until it is published.
25+
26+## Try it (headless)
27+
28+Run `nefertty --backend=headless`, then connect with
29+`WAYLAND_DISPLAY=<logged name> foot`. The headless backend does not render yet.
@@ -22,3 +22,8 @@ gaps = 8
22 See `examples/config.toml` for all defaults.22 See `examples/config.toml` for all defaults.
23 23
24 Building requires a sibling checkout of purego-libwayland until it is published.24 Building requires a sibling checkout of purego-libwayland until it is published.
25+
26+## Try it (headless)
27+
28+Run `nefertty --backend=headless`, then connect with
29+`WAYLAND_DISPLAY=<logged name> foot`. The headless backend does not render yet.
M cmd/nefertty/main.go +2 -1
@@ -78,6 +78,7 @@ func run() error {
7878 }
7979 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
8080 backend := flags.String("backend", "drm", "drm or headless")
81+ noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
8182 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
8283 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
8384 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -131,7 +132,7 @@ func run() error {
131132 log := logging.For(ctx, "app")
132133 configLog := logging.For(ctx, "config")
133134 configLog.Info().Str("path", path).Msg("loaded config")
134- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {
135+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal}); err != nil {
135136 // SIGINT and SIGTERM cancel the context and are clean exits.
136137 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
137138 return nil
@@ -78,6 +78,7 @@ func run() error {
78 }78 }
79 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)79 flags := flag.NewFlagSet("nefertty", flag.ContinueOnError)
80 backend := flags.String("backend", "drm", "drm or headless")80 backend := flags.String("backend", "drm", "drm or headless")
81+ noTerminal := flags.Bool("no-terminal", false, "skip initial terminal")
81 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")82 timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)")
82 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")83 debugFlag := flags.String("debug", "", "debug components (comma-separated or all)")
83 configFlag := flags.String("config", "", "config path (empty uses XDG default)")84 configFlag := flags.String("config", "", "config path (empty uses XDG default)")
@@ -131,7 +132,7 @@ func run() error {
131 log := logging.For(ctx, "app")132 log := logging.For(ctx, "app")
132 configLog := logging.For(ctx, "config")133 configLog := logging.For(ctx, "config")
133 configLog.Info().Str("path", path).Msg("loaded config")134 configLog.Info().Str("path", path).Msg("loaded config")
134- if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout}); err != nil {135+ if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal}); err != nil {
135 // SIGINT and SIGTERM cancel the context and are clean exits.136 // SIGINT and SIGTERM cancel the context and are clean exits.
136 if ctx.Err() != nil && errors.Is(err, context.Canceled) {137 if ctx.Err() != nil && errors.Is(err, context.Canceled) {
137 return nil138 return nil
A internal/adapters/launcher/launcher.go +82 -0
@@ -0,0 +1,82 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "sort"
7+ "strings"
8+ "syscall"
9+
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+)
13+
14+type Launcher struct {
15+ env []string
16+ log zerowrap.Logger
17+}
18+
19+func New(env []string, log zerowrap.Logger) *Launcher {
20+ return &Launcher{env: append([]string(nil), env...), log: log}
21+}
22+
23+func ChildEnv(base []string, waylandDisplay, runtimeDir string) []string {
24+ values := map[string]string{}
25+ for _, entry := range base {
26+ key, value, ok := strings.Cut(entry, "=")
27+ if !ok {
28+ continue
29+ }
30+ switch {
31+ case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"):
32+ values[key] = value
33+ }
34+ }
35+ values["WAYLAND_DISPLAY"] = waylandDisplay
36+ values["XDG_RUNTIME_DIR"] = runtimeDir
37+ values["XDG_CURRENT_DESKTOP"] = "nefertty"
38+ values["XDG_SESSION_TYPE"] = "wayland"
39+ keys := make([]string, 0, len(values))
40+ for key := range values {
41+ keys = append(keys, key)
42+ }
43+ sort.Strings(keys)
44+ env := make([]string, 0, len(keys))
45+ for _, key := range keys {
46+ env = append(env, key+"="+values[key])
47+ }
48+ return env
49+}
50+
51+func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) error {
52+ for {
53+ select {
54+ case <-ctx.Done():
55+ return nil
56+ case req, ok := <-reqs:
57+ if !ok {
58+ return nil
59+ }
60+ if len(req.Argv) == 0 {
61+ l.log.Warn().Msg("empty spawn argv")
62+ continue
63+ }
64+ path, err := exec.LookPath(req.Argv[0])
65+ if err != nil {
66+ l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
67+ continue
68+ }
69+ cmd := exec.Command(path, req.Argv[1:]...)
70+ cmd.Env = l.env
71+ cmd.Stdin = nil
72+ cmd.Stdout = nil
73+ cmd.Stderr = nil
74+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
75+ if err := cmd.Start(); err != nil {
76+ l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
77+ continue
78+ }
79+ go func() { l.log.Debug().Err(cmd.Wait()).Str("binary", req.Argv[0]).Msg("child exited") }()
80+ }
81+ }
82+}
@@ -0,0 +1,82 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os/exec"
6+ "sort"
7+ "strings"
8+ "syscall"
9+
10+ "github.com/bnema/nefertty/internal/ports"
11+ "github.com/bnema/zerowrap"
12+)
13+
14+type Launcher struct {
15+ env []string
16+ log zerowrap.Logger
17+}
18+
19+func New(env []string, log zerowrap.Logger) *Launcher {
20+ return &Launcher{env: append([]string(nil), env...), log: log}
21+}
22+
23+func ChildEnv(base []string, waylandDisplay, runtimeDir string) []string {
24+ values := map[string]string{}
25+ for _, entry := range base {
26+ key, value, ok := strings.Cut(entry, "=")
27+ if !ok {
28+ continue
29+ }
30+ switch {
31+ case key == "HOME", key == "USER", key == "LOGNAME", key == "SHELL", key == "PATH", key == "LANG", strings.HasPrefix(key, "LC_"), strings.HasPrefix(key, "XDG_"):
32+ values[key] = value
33+ }
34+ }
35+ values["WAYLAND_DISPLAY"] = waylandDisplay
36+ values["XDG_RUNTIME_DIR"] = runtimeDir
37+ values["XDG_CURRENT_DESKTOP"] = "nefertty"
38+ values["XDG_SESSION_TYPE"] = "wayland"
39+ keys := make([]string, 0, len(values))
40+ for key := range values {
41+ keys = append(keys, key)
42+ }
43+ sort.Strings(keys)
44+ env := make([]string, 0, len(keys))
45+ for _, key := range keys {
46+ env = append(env, key+"="+values[key])
47+ }
48+ return env
49+}
50+
51+func (l *Launcher) Run(ctx context.Context, reqs <-chan ports.SpawnRequest) error {
52+ for {
53+ select {
54+ case <-ctx.Done():
55+ return nil
56+ case req, ok := <-reqs:
57+ if !ok {
58+ return nil
59+ }
60+ if len(req.Argv) == 0 {
61+ l.log.Warn().Msg("empty spawn argv")
62+ continue
63+ }
64+ path, err := exec.LookPath(req.Argv[0])
65+ if err != nil {
66+ l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
67+ continue
68+ }
69+ cmd := exec.Command(path, req.Argv[1:]...)
70+ cmd.Env = l.env
71+ cmd.Stdin = nil
72+ cmd.Stdout = nil
73+ cmd.Stderr = nil
74+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
75+ if err := cmd.Start(); err != nil {
76+ l.log.Warn().Err(err).Str("binary", req.Argv[0]).Msg("spawn failed")
77+ continue
78+ }
79+ go func() { l.log.Debug().Err(cmd.Wait()).Str("binary", req.Argv[0]).Msg("child exited") }()
80+ }
81+ }
82+}
A internal/adapters/launcher/launcher_test.go +59 -0
@@ -0,0 +1,59 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/nefertty/internal/logging"
12+ "github.com/bnema/nefertty/internal/ports"
13+)
14+
15+func TestChildEnv(t *testing.T) {
16+ got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "SECRET=bad"}, "wayland-7", "/run/me")
17+ want := []string{"HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XDG_CURRENT_DESKTOP=nefertty", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"}
18+ if !reflect.DeepEqual(got, want) {
19+ t.Fatalf("got %v want %v", got, want)
20+ }
21+}
22+
23+func TestRun(t *testing.T) {
24+ dir := t.TempDir()
25+ file := filepath.Join(dir, "result")
26+ ctx, cancel := context.WithCancel(context.Background())
27+ done := make(chan error, 1)
28+ reqs := make(chan ports.SpawnRequest, 3)
29+ go func() {
30+ done <- New([]string{"PATH=/bin", "FILE=" + file}, logging.For(ctx, "launcher")).Run(ctx, reqs)
31+ }()
32+ reqs <- ports.SpawnRequest{}
33+ reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-nefertty-binary"}}
34+ reqs <- ports.SpawnRequest{Argv: []string{"sh", "-c", "echo ok > $FILE"}}
35+ deadline := time.After(3 * time.Second)
36+ for {
37+ data, err := os.ReadFile(file)
38+ if err == nil {
39+ if string(data) != "ok\n" {
40+ t.Fatalf("output: %q", data)
41+ }
42+ break
43+ }
44+ select {
45+ case <-deadline:
46+ t.Fatal("child did not write")
47+ case <-time.After(10 * time.Millisecond):
48+ }
49+ }
50+ cancel()
51+ select {
52+ case err := <-done:
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ case <-time.After(time.Second):
57+ t.Fatal("launcher did not stop")
58+ }
59+}
@@ -0,0 +1,59 @@
1+package launcher
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "reflect"
8+ "testing"
9+ "time"
10+
11+ "github.com/bnema/nefertty/internal/logging"
12+ "github.com/bnema/nefertty/internal/ports"
13+)
14+
15+func TestChildEnv(t *testing.T) {
16+ got := ChildEnv([]string{"TERM=x", "DISPLAY=:0", "WAYLAND_SOCKET=4", "WAYLAND_DISPLAY=old", "HOME=/home/me", "PATH=/bin", "LC_ALL=C", "XDG_RUNTIME_DIR=old", "SECRET=bad"}, "wayland-7", "/run/me")
17+ want := []string{"HOME=/home/me", "LC_ALL=C", "PATH=/bin", "WAYLAND_DISPLAY=wayland-7", "XDG_CURRENT_DESKTOP=nefertty", "XDG_RUNTIME_DIR=/run/me", "XDG_SESSION_TYPE=wayland"}
18+ if !reflect.DeepEqual(got, want) {
19+ t.Fatalf("got %v want %v", got, want)
20+ }
21+}
22+
23+func TestRun(t *testing.T) {
24+ dir := t.TempDir()
25+ file := filepath.Join(dir, "result")
26+ ctx, cancel := context.WithCancel(context.Background())
27+ done := make(chan error, 1)
28+ reqs := make(chan ports.SpawnRequest, 3)
29+ go func() {
30+ done <- New([]string{"PATH=/bin", "FILE=" + file}, logging.For(ctx, "launcher")).Run(ctx, reqs)
31+ }()
32+ reqs <- ports.SpawnRequest{}
33+ reqs <- ports.SpawnRequest{Argv: []string{"nonexistent-nefertty-binary"}}
34+ reqs <- ports.SpawnRequest{Argv: []string{"sh", "-c", "echo ok > $FILE"}}
35+ deadline := time.After(3 * time.Second)
36+ for {
37+ data, err := os.ReadFile(file)
38+ if err == nil {
39+ if string(data) != "ok\n" {
40+ t.Fatalf("output: %q", data)
41+ }
42+ break
43+ }
44+ select {
45+ case <-deadline:
46+ t.Fatal("child did not write")
47+ case <-time.After(10 * time.Millisecond):
48+ }
49+ }
50+ cancel()
51+ select {
52+ case err := <-done:
53+ if err != nil {
54+ t.Fatal(err)
55+ }
56+ case <-time.After(time.Second):
57+ t.Fatal("launcher did not stop")
58+ }
59+}
M internal/app/app.go +54 -25
@@ -4,24 +4,33 @@ package app
44 import (
55 "context"
66 "errors"
7+ "fmt"
8+ "os"
79 "sync"
810 "time"
911
12+ "github.com/bnema/nefertty/internal/adapters/launcher"
13+ "github.com/bnema/nefertty/internal/adapters/wayland"
1014 "github.com/bnema/nefertty/internal/core"
1115 "github.com/bnema/nefertty/internal/logging"
1216 "github.com/bnema/nefertty/internal/ports"
1317 )
1418
1519 type Options struct {
16- Backend string
17- Config ports.Config
18- Timeout time.Duration
20+ Backend string
21+ Config ports.Config
22+ Timeout time.Duration
23+ NoTerminal bool
24+ testScenes chan<- ports.Scene
1925 }
2026
2127 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
2228
2329 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
2430 log := logging.For(ctx, "app")
31+ if opts.Backend != "headless" {
32+ return fmt.Errorf("drm backend not implemented yet")
33+ }
2534 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
2635 var cancel context.CancelFunc
2736 if opts.Timeout > 0 {
@@ -43,34 +52,28 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
4352 if err != nil {
4453 return err
4554 }
46- if opts.Backend == "headless" {
47- output <- ports.OutputMode{Width: 1920, Height: 1080}
55+ output <- ports.OutputMode{Width: 1920, Height: 1080}
56+ runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
57+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080}, wayland.Channels{Events: client, Commands: commands}, logging.For(ctx, "wayland"))
58+ if err != nil {
59+ return err
60+ }
61+ log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
62+ child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher"))
63+ if !opts.NoTerminal {
64+ spawn <- ports.SpawnRequest{Argv: append([]string(nil), opts.Config.Terminal.Command...)}
4865 }
4966 if inject != nil {
5067 inject(input)
5168 }
5269 var workers sync.WaitGroup
53- workers.Add(2)
54- done := make(chan error, 1)
70+ workers.Add(4)
71+ done := make(chan error, 3)
72+ go func() { defer workers.Done(); done <- server.Run(ctx) }()
73+ go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
5574 go func() { defer workers.Done(); done <- c.Run(ctx) }()
56- go func() {
57- defer workers.Done()
58- log := logging.For(ctx, "core")
59- for {
60- select {
61- case <-ctx.Done():
62- return
63- case s := <-scenes:
64- log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
65- case v := <-commands:
66- log.Debug().Interface("command", v).Msg("command")
67- case v := <-spawn:
68- log.Debug().Interface("spawn", v).Msg("spawn")
69- case e := <-configErrors:
70- log.Debug().Err(e).Msg("config rejected")
71- }
72- }
73- }()
75+ go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes) }()
76+
7477 var result error
7578 select {
7679 case <-ctx.Done():
@@ -78,8 +81,34 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
7881 }
7982 cancel()
8083 workers.Wait()
84+ for len(done) > 0 {
85+ err := <-done
86+ if result == nil && err != nil {
87+ result = err
88+ }
89+ }
8190 if errors.Is(result, core.ErrQuit) {
8291 return nil
8392 }
8493 return result
8594 }
95+
96+func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene) {
97+ log := logging.For(ctx, "core")
98+ for {
99+ select {
100+ case <-ctx.Done():
101+ return
102+ case s := <-scenes:
103+ if tap != nil {
104+ select {
105+ case tap <- s:
106+ default:
107+ }
108+ }
109+ log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
110+ case err := <-configErrors:
111+ log.Debug().Err(err).Msg("config rejected")
112+ }
113+ }
114+}
@@ -4,24 +4,33 @@ package app
4 import (4 import (
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
8+ "os"
7 "sync"9 "sync"
8 "time"10 "time"
9 11
12+ "github.com/bnema/nefertty/internal/adapters/launcher"
13+ "github.com/bnema/nefertty/internal/adapters/wayland"
10 "github.com/bnema/nefertty/internal/core"14 "github.com/bnema/nefertty/internal/core"
11 "github.com/bnema/nefertty/internal/logging"15 "github.com/bnema/nefertty/internal/logging"
12 "github.com/bnema/nefertty/internal/ports"16 "github.com/bnema/nefertty/internal/ports"
13 )17 )
14 18
15 type Options struct {19 type Options struct {
16- Backend string20+ Backend string
17- Config ports.Config21+ Config ports.Config
18- Timeout time.Duration22+ Timeout time.Duration
23+ NoTerminal bool
24+ testScenes chan<- ports.Scene
19 }25 }
20 26
21 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }27 func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) }
22 28
23 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {29 func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error {
24 log := logging.For(ctx, "app")30 log := logging.For(ctx, "app")
31+ if opts.Backend != "headless" {
32+ return fmt.Errorf("drm backend not implemented yet")
33+ }
25 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")34 log.Info().Str("backend", opts.Backend).Msg("starting nefertty")
26 var cancel context.CancelFunc35 var cancel context.CancelFunc
27 if opts.Timeout > 0 {36 if opts.Timeout > 0 {
@@ -43,34 +52,28 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
43 if err != nil {52 if err != nil {
44 return err53 return err
45 }54 }
46- if opts.Backend == "headless" {55+ output <- ports.OutputMode{Width: 1920, Height: 1080}
47- output <- ports.OutputMode{Width: 1920, Height: 1080}56+ runtimeDir := os.Getenv("XDG_RUNTIME_DIR")
57+ server, err := wayland.New(wayland.Options{RuntimeDir: runtimeDir, OutputWidth: 1920, OutputHeight: 1080}, wayland.Channels{Events: client, Commands: commands}, logging.For(ctx, "wayland"))
58+ if err != nil {
59+ return err
60+ }
61+ log.Info().Str("WAYLAND_DISPLAY", server.SocketName()).Msg("listening")
62+ child := launcher.New(launcher.ChildEnv(os.Environ(), server.SocketName(), runtimeDir), logging.For(ctx, "launcher"))
63+ if !opts.NoTerminal {
64+ spawn <- ports.SpawnRequest{Argv: append([]string(nil), opts.Config.Terminal.Command...)}
48 }65 }
49 if inject != nil {66 if inject != nil {
50 inject(input)67 inject(input)
51 }68 }
52 var workers sync.WaitGroup69 var workers sync.WaitGroup
53- workers.Add(2)70+ workers.Add(4)
54- done := make(chan error, 1)71+ done := make(chan error, 3)
72+ go func() { defer workers.Done(); done <- server.Run(ctx) }()
73+ go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }()
55 go func() { defer workers.Done(); done <- c.Run(ctx) }()74 go func() { defer workers.Done(); done <- c.Run(ctx) }()
56- go func() {75+ go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes) }()
57- defer workers.Done()76+
58- log := logging.For(ctx, "core")
59- for {
60- select {
61- case <-ctx.Done():
62- return
63- case s := <-scenes:
64- log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
65- case v := <-commands:
66- log.Debug().Interface("command", v).Msg("command")
67- case v := <-spawn:
68- log.Debug().Interface("spawn", v).Msg("spawn")
69- case e := <-configErrors:
70- log.Debug().Err(e).Msg("config rejected")
71- }
72- }
73- }()
74 var result error77 var result error
75 select {78 select {
76 case <-ctx.Done():79 case <-ctx.Done():
@@ -78,8 +81,34 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)
78 }81 }
79 cancel()82 cancel()
80 workers.Wait()83 workers.Wait()
84+ for len(done) > 0 {
85+ err := <-done
86+ if result == nil && err != nil {
87+ result = err
88+ }
89+ }
81 if errors.Is(result, core.ErrQuit) {90 if errors.Is(result, core.ErrQuit) {
82 return nil91 return nil
83 }92 }
84 return result93 return result
85 }94 }
95+
96+func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene) {
97+ log := logging.For(ctx, "core")
98+ for {
99+ select {
100+ case <-ctx.Done():
101+ return
102+ case s := <-scenes:
103+ if tap != nil {
104+ select {
105+ case tap <- s:
106+ default:
107+ }
108+ }
109+ log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene")
110+ case err := <-configErrors:
111+ log.Debug().Err(err).Msg("config rejected")
112+ }
113+ }
114+}
M internal/app/app_test.go +61 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "github.com/bnema/nefertty/internal/adapters/config"
66 "github.com/bnema/nefertty/internal/ports"
7+ "os/exec"
78 "testing"
89 "time"
910 )
@@ -11,7 +12,7 @@ import (
1112 func TestQuitJoinsWorkers(t *testing.T) {
1213 done := make(chan error, 1)
1314 go func() {
14- done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults()}, func(input chan<- ports.InputEvent) {
15+ done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
1516 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
1617 })
1718 }()
@@ -24,3 +25,62 @@ func TestQuitJoinsWorkers(t *testing.T) {
2425 t.Fatal("quit did not join workers")
2526 }
2627 }
28+
29+func TestHeadlessSpawnClose(t *testing.T) {
30+ if _, err := exec.LookPath("weston-simple-shm"); err != nil {
31+ t.Skip("weston-simple-shm not installed")
32+ }
33+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
34+ t.Setenv("XDG_STATE_HOME", t.TempDir())
35+ cfg := config.Defaults()
36+ cfg.Terminal.Command = []string{"weston-simple-shm"}
37+ scenes := make(chan ports.Scene, 64)
38+ ctx, cancel := context.WithCancel(context.Background())
39+ defer cancel()
40+ inputReady := make(chan chan<- ports.InputEvent, 1)
41+ done := make(chan error, 1)
42+ go func() {
43+ done <- run(ctx, Options{Backend: "headless", NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })
44+ }()
45+ var input chan<- ports.InputEvent
46+ select {
47+ case input = <-inputReady:
48+ case <-time.After(3 * time.Second):
49+ t.Fatal("app did not start")
50+ }
51+ defer func() {
52+ cancel()
53+ select {
54+ case err := <-done:
55+ if err != nil {
56+ t.Errorf("Run: %v", err)
57+ }
58+ case <-time.After(3 * time.Second):
59+ t.Error("Run did not stop")
60+ }
61+ }()
62+ input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}
63+ waitScene := func(count int, timeout time.Duration) {
64+ t.Helper()
65+ timer := time.NewTimer(timeout)
66+ defer timer.Stop()
67+ for {
68+ select {
69+ case s := <-scenes:
70+ if len(s.Windows) == count {
71+ if count == 1 && s.Windows[0].Rect.W != 948 {
72+ t.Errorf("width = %d, want 948", s.Windows[0].Rect.W)
73+ }
74+ return
75+ }
76+ case <-timer.C:
77+ t.Fatalf("no scene with %d windows", count)
78+ case err := <-done:
79+ t.Fatalf("app exited early: %v", err)
80+ }
81+ }
82+ }
83+ waitScene(1, 10*time.Second)
84+ input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}
85+ waitScene(0, 5*time.Second)
86+}
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "github.com/bnema/nefertty/internal/adapters/config"5 "github.com/bnema/nefertty/internal/adapters/config"
6 "github.com/bnema/nefertty/internal/ports"6 "github.com/bnema/nefertty/internal/ports"
7+ "os/exec"
7 "testing"8 "testing"
8 "time"9 "time"
9 )10 )
@@ -11,7 +12,7 @@ import (
11 func TestQuitJoinsWorkers(t *testing.T) {12 func TestQuitJoinsWorkers(t *testing.T) {
12 done := make(chan error, 1)13 done := make(chan error, 1)
13 go func() {14 go func() {
14- done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults()}, func(input chan<- ports.InputEvent) {15+ done <- run(context.Background(), Options{Backend: "headless", Config: config.Defaults(), NoTerminal: true}, func(input chan<- ports.InputEvent) {
15 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}16 input <- ports.KeyEvent{Keysym: "BackSpace", Mods: ports.ModCtrl | ports.ModAlt, Pressed: true}
16 })17 })
17 }()18 }()
@@ -24,3 +25,62 @@ func TestQuitJoinsWorkers(t *testing.T) {
24 t.Fatal("quit did not join workers")25 t.Fatal("quit did not join workers")
25 }26 }
26 }27 }
28+
29+func TestHeadlessSpawnClose(t *testing.T) {
30+ if _, err := exec.LookPath("weston-simple-shm"); err != nil {
31+ t.Skip("weston-simple-shm not installed")
32+ }
33+ t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
34+ t.Setenv("XDG_STATE_HOME", t.TempDir())
35+ cfg := config.Defaults()
36+ cfg.Terminal.Command = []string{"weston-simple-shm"}
37+ scenes := make(chan ports.Scene, 64)
38+ ctx, cancel := context.WithCancel(context.Background())
39+ defer cancel()
40+ inputReady := make(chan chan<- ports.InputEvent, 1)
41+ done := make(chan error, 1)
42+ go func() {
43+ done <- run(ctx, Options{Backend: "headless", NoTerminal: true, Config: cfg, testScenes: scenes}, func(input chan<- ports.InputEvent) { inputReady <- input })
44+ }()
45+ var input chan<- ports.InputEvent
46+ select {
47+ case input = <-inputReady:
48+ case <-time.After(3 * time.Second):
49+ t.Fatal("app did not start")
50+ }
51+ defer func() {
52+ cancel()
53+ select {
54+ case err := <-done:
55+ if err != nil {
56+ t.Errorf("Run: %v", err)
57+ }
58+ case <-time.After(3 * time.Second):
59+ t.Error("Run did not stop")
60+ }
61+ }()
62+ input <- ports.KeyEvent{Keysym: "Return", Mods: ports.ModSuper, Pressed: true}
63+ waitScene := func(count int, timeout time.Duration) {
64+ t.Helper()
65+ timer := time.NewTimer(timeout)
66+ defer timer.Stop()
67+ for {
68+ select {
69+ case s := <-scenes:
70+ if len(s.Windows) == count {
71+ if count == 1 && s.Windows[0].Rect.W != 948 {
72+ t.Errorf("width = %d, want 948", s.Windows[0].Rect.W)
73+ }
74+ return
75+ }
76+ case <-timer.C:
77+ t.Fatalf("no scene with %d windows", count)
78+ case err := <-done:
79+ t.Fatalf("app exited early: %v", err)
80+ }
81+ }
82+ }
83+ waitScene(1, 10*time.Second)
84+ input <- ports.KeyEvent{Keysym: "q", Mods: ports.ModSuper, Pressed: true}
85+ waitScene(0, 5*time.Second)
86+}

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