feat(app): wire headless wayland server, core and launcher
- commit
- a92355b31984129df9e3469249df75d8970d2897
- author
- brice · · 2026-09-23 12:22 UTC
- parent
- 812b032
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
7 files · +266 -27
M .hexcheck.yaml +3 -0
| @@ -19,3 +19,6 @@ ruleSettings: | |||
| 19 | 19 | missing-generated-mock-for-port: | |
| 20 | 20 | # Sealed message unions are channel data, not ports; tested by sending messages (ADR 005). | |
| 21 | 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] | |
| @@ -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 | |||
| 22 | 22 | See `examples/config.toml` for all defaults. | |
| 23 | 23 | ||
| 24 | 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. | |
| @@ -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 { | |||
| 78 | 78 | } | |
| 79 | 79 | flags := flag.NewFlagSet("nefertty", flag.ContinueOnError) | |
| 80 | 80 | backend := flags.String("backend", "drm", "drm or headless") | |
| 81 | + | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 81 | 82 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 82 | 83 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |
| 83 | 84 | configFlag := flags.String("config", "", "config path (empty uses XDG default)") | |
| @@ -131,7 +132,7 @@ func run() error { | |||
| 131 | 132 | log := logging.For(ctx, "app") | |
| 132 | 133 | configLog := logging.For(ctx, "config") | |
| 133 | 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 | 136 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 136 | 137 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 137 | 138 | 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 nil | 138 | 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 | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "os" | |
| 7 | 9 | "sync" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| 12 | + | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 13 | + | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 10 | 14 | "github.com/bnema/nefertty/internal/core" | |
| 11 | 15 | "github.com/bnema/nefertty/internal/logging" | |
| 12 | 16 | "github.com/bnema/nefertty/internal/ports" | |
| 13 | 17 | ) | |
| 14 | 18 | ||
| 15 | 19 | 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 | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 21 | 27 | func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | |
| 22 | 28 | ||
| 23 | 29 | func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent)) error { | |
| 24 | 30 | log := logging.For(ctx, "app") | |
| 31 | + | if opts.Backend != "headless" { | |
| 32 | + | return fmt.Errorf("drm backend not implemented yet") | |
| 33 | + | } | |
| 25 | 34 | log.Info().Str("backend", opts.Backend).Msg("starting nefertty") | |
| 26 | 35 | var cancel context.CancelFunc | |
| 27 | 36 | if opts.Timeout > 0 { | |
| @@ -43,34 +52,28 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 43 | 52 | if err != nil { | |
| 44 | 53 | return err | |
| 45 | 54 | } | |
| 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...)} | |
| 48 | 65 | } | |
| 49 | 66 | if inject != nil { | |
| 50 | 67 | inject(input) | |
| 51 | 68 | } | |
| 52 | 69 | 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) }() | |
| 55 | 74 | 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 | + | ||
| 74 | 77 | var result error | |
| 75 | 78 | select { | |
| 76 | 79 | case <-ctx.Done(): | |
| @@ -78,8 +81,34 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 78 | 81 | } | |
| 79 | 82 | cancel() | |
| 80 | 83 | workers.Wait() | |
| 84 | + | for len(done) > 0 { | |
| 85 | + | err := <-done | |
| 86 | + | if result == nil && err != nil { | |
| 87 | + | result = err | |
| 88 | + | } | |
| 89 | + | } | |
| 81 | 90 | if errors.Is(result, core.ErrQuit) { | |
| 82 | 91 | return nil | |
| 83 | 92 | } | |
| 84 | 93 | 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 | + | } | |
| @@ -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 string | 20 | + | Backend string |
| 17 | - | Config ports.Config | 21 | + | Config ports.Config |
| 18 | - | Timeout time.Duration | 22 | + | 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.CancelFunc | 35 | 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 err | 53 | 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.WaitGroup | 69 | 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 error | 77 | 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 nil | 91 | return nil | ||
| 83 | } | 92 | } | ||
| 84 | return result | 93 | 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 ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "github.com/bnema/nefertty/internal/adapters/config" | |
| 6 | 6 | "github.com/bnema/nefertty/internal/ports" | |
| 7 | + | "os/exec" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| 9 | 10 | ) | |
| @@ -11,7 +12,7 @@ import ( | |||
| 11 | 12 | func TestQuitJoinsWorkers(t *testing.T) { | |
| 12 | 13 | done := make(chan error, 1) | |
| 13 | 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 | 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 | 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 | + | } | |
| @@ -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 | + | } | |||