feat(headless): add output goroutine with PNG screenshots
- commit
- 6000865e4608e4f922a12703563ac1ebf343e640
- author
- brice · · 2026-09-23 15:26 UTC
- parent
- f1e2d50
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +310 -12
M README.md +2 -2
| @@ -25,5 +25,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until | |||
| 25 | 25 | ||
| 26 | 26 | ## Try it (headless) | |
| 27 | 27 | ||
| 28 | - | Run `nefertty --backend=headless`, then connect with | |
| 29 | - | `WAYLAND_DISPLAY=<logged name> foot`. The headless backend does not render yet. | |
| 28 | + | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with | |
| 29 | + | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. | |
| @@ -25,5 +25,5 @@ Building requires sibling checkouts of purego-libwayland and purego-vulkan until | |||||
| 25 | 25 | ||||
| 26 | ## Try it (headless) | 26 | ## Try it (headless) | ||
| 27 | 27 | ||||
| 28 | - | Run `nefertty --backend=headless`, then connect with | 28 | + | Run `nefertty --backend=headless --screenshot /tmp/nefertty-shots`, then connect with |
| 29 | - | `WAYLAND_DISPLAY=<logged name> foot`. The headless backend does not render yet. | 29 | + | `WAYLAND_DISPLAY=<logged name> foot`. `/tmp/nefertty-shots/latest.png` shows the current frame. |
M cmd/nefertty/main.go +13 -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 | + | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |
| 81 | 82 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | |
| 82 | 83 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | |
| 83 | 84 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | |
| @@ -93,6 +94,11 @@ func run() error { | |||
| 93 | 94 | fmt.Fprintln(os.Stderr, err) | |
| 94 | 95 | return err | |
| 95 | 96 | } | |
| 97 | + | if *screenshot != "" && *backend != "headless" { | |
| 98 | + | err := usageError{fmt.Errorf("--screenshot requires --backend=headless")} | |
| 99 | + | fmt.Fprintln(os.Stderr, err) | |
| 100 | + | return err | |
| 101 | + | } | |
| 96 | 102 | if *timeout < 0 { | |
| 97 | 103 | err := usageError{fmt.Errorf("negative timeout")} | |
| 98 | 104 | fmt.Fprintln(os.Stderr, err) | |
| @@ -103,6 +109,12 @@ func run() error { | |||
| 103 | 109 | fmt.Fprintln(os.Stderr, err) | |
| 104 | 110 | return err | |
| 105 | 111 | } | |
| 112 | + | if *screenshot != "" { | |
| 113 | + | if err := os.MkdirAll(*screenshot, 0o755); err != nil { | |
| 114 | + | fmt.Fprintln(os.Stderr, err) | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | } | |
| 106 | 118 | path := *configFlag | |
| 107 | 119 | var cfgErr error | |
| 108 | 120 | var cfg = config.Defaults() | |
| @@ -132,7 +144,7 @@ func run() error { | |||
| 132 | 144 | log := logging.For(ctx, "app") | |
| 133 | 145 | configLog := logging.For(ctx, "config") | |
| 134 | 146 | configLog.Info().Str("path", path).Msg("loaded config") | |
| 135 | - | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal}); err != nil { | |
| 147 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil { | |
| 136 | 148 | // SIGINT and SIGTERM cancel the context and are clean exits. | |
| 137 | 149 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | |
| 138 | 150 | 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 | + | screenshot := flags.String("screenshot", "", "write PNG frames to directory") | |||
| 81 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | 82 | noTerminal := flags.Bool("no-terminal", false, "skip initial terminal") | ||
| 82 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | 83 | timeout := flags.Duration("timeout", 0, "duration before exit (0 disables timeout)") | ||
| 83 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | 84 | debugFlag := flags.String("debug", "", "debug components (comma-separated or all)") | ||
| @@ -93,6 +94,11 @@ func run() error { | |||||
| 93 | fmt.Fprintln(os.Stderr, err) | 94 | fmt.Fprintln(os.Stderr, err) | ||
| 94 | return err | 95 | return err | ||
| 95 | } | 96 | } | ||
| 97 | + | if *screenshot != "" && *backend != "headless" { | |||
| 98 | + | err := usageError{fmt.Errorf("--screenshot requires --backend=headless")} | |||
| 99 | + | fmt.Fprintln(os.Stderr, err) | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 96 | if *timeout < 0 { | 102 | if *timeout < 0 { | ||
| 97 | err := usageError{fmt.Errorf("negative timeout")} | 103 | err := usageError{fmt.Errorf("negative timeout")} | ||
| 98 | fmt.Fprintln(os.Stderr, err) | 104 | fmt.Fprintln(os.Stderr, err) | ||
| @@ -103,6 +109,12 @@ func run() error { | |||||
| 103 | fmt.Fprintln(os.Stderr, err) | 109 | fmt.Fprintln(os.Stderr, err) | ||
| 104 | return err | 110 | return err | ||
| 105 | } | 111 | } | ||
| 112 | + | if *screenshot != "" { | |||
| 113 | + | if err := os.MkdirAll(*screenshot, 0o755); err != nil { | |||
| 114 | + | fmt.Fprintln(os.Stderr, err) | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 106 | path := *configFlag | 118 | path := *configFlag | ||
| 107 | var cfgErr error | 119 | var cfgErr error | ||
| 108 | var cfg = config.Defaults() | 120 | var cfg = config.Defaults() | ||
| @@ -132,7 +144,7 @@ func run() error { | |||||
| 132 | log := logging.For(ctx, "app") | 144 | log := logging.For(ctx, "app") | ||
| 133 | configLog := logging.For(ctx, "config") | 145 | configLog := logging.For(ctx, "config") | ||
| 134 | configLog.Info().Str("path", path).Msg("loaded config") | 146 | configLog.Info().Str("path", path).Msg("loaded config") | ||
| 135 | - | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal}); err != nil { | 147 | + | if err := app.Run(ctx, app.Options{Backend: *backend, Config: cfg, Timeout: *timeout, NoTerminal: *noTerminal, ScreenshotDir: *screenshot}); err != nil { |
| 136 | // SIGINT and SIGTERM cancel the context and are clean exits. | 148 | // SIGINT and SIGTERM cancel the context and are clean exits. | ||
| 137 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | 149 | if ctx.Err() != nil && errors.Is(err, context.Canceled) { | ||
| 138 | return nil | 150 | return nil | ||
M cmd/nefertty/main_test.go +1 -0
| @@ -21,6 +21,7 @@ func TestExitCodes(t *testing.T) { | |||
| 21 | 21 | }{ | |
| 22 | 22 | {[]string{"nefertty", "--help"}, 0}, | |
| 23 | 23 | {[]string{"nefertty", "--backend=invalid"}, 2}, | |
| 24 | + | {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | |
| 24 | 25 | {[]string{"nefertty", "--timeout=-1s"}, 2}, | |
| 25 | 26 | {[]string{"nefertty", "extra"}, 2}, | |
| 26 | 27 | {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1}, | |
| @@ -21,6 +21,7 @@ func TestExitCodes(t *testing.T) { | |||||
| 21 | }{ | 21 | }{ | ||
| 22 | {[]string{"nefertty", "--help"}, 0}, | 22 | {[]string{"nefertty", "--help"}, 0}, | ||
| 23 | {[]string{"nefertty", "--backend=invalid"}, 2}, | 23 | {[]string{"nefertty", "--backend=invalid"}, 2}, | ||
| 24 | + | {[]string{"nefertty", "--backend=drm", "--screenshot=/tmp/shots"}, 2}, | |||
| 24 | {[]string{"nefertty", "--timeout=-1s"}, 2}, | 25 | {[]string{"nefertty", "--timeout=-1s"}, 2}, | ||
| 25 | {[]string{"nefertty", "extra"}, 2}, | 26 | {[]string{"nefertty", "extra"}, 2}, | ||
| 26 | {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1}, | 27 | {[]string{"nefertty", "--config=/nonexistent/nefertty.toml"}, 1}, | ||
A internal/adapters/headless/output.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "image" | |
| 7 | + | "image/png" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/nefertty/internal/ports" | |
| 13 | + | "github.com/bnema/zerowrap" | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | type Renderer interface { | |
| 17 | + | Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error | |
| 18 | + | Pixels() *image.RGBA | |
| 19 | + | Close() | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | type Options struct { | |
| 23 | + | Width, Height int | |
| 24 | + | ScreenshotDir string | |
| 25 | + | Log zerowrap.Logger | |
| 26 | + | NewRenderer func(w, h int) (Renderer, error) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error { | |
| 30 | + | r, err := opts.NewRenderer(opts.Width, opts.Height) | |
| 31 | + | if err != nil { | |
| 32 | + | return fmt.Errorf("create renderer: %w", err) | |
| 33 | + | } | |
| 34 | + | defer r.Close() | |
| 35 | + | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |
| 36 | + | var scene ports.Scene | |
| 37 | + | haveScene := false | |
| 38 | + | frame := 0 | |
| 39 | + | update := func(c ports.SurfaceContent) { | |
| 40 | + | if c.Pixels == nil { | |
| 41 | + | delete(surfaces, c.ID) | |
| 42 | + | } else { | |
| 43 | + | surfaces[c.ID] = c | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | for { | |
| 47 | + | if ctx.Err() != nil { | |
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | select { | |
| 51 | + | case <-ctx.Done(): | |
| 52 | + | return nil | |
| 53 | + | case s, ok := <-scenes: | |
| 54 | + | if !ok { | |
| 55 | + | scenes = nil | |
| 56 | + | continue | |
| 57 | + | } | |
| 58 | + | scene, haveScene = s, true | |
| 59 | + | case c, ok := <-contents: | |
| 60 | + | if !ok { | |
| 61 | + | contents = nil | |
| 62 | + | continue | |
| 63 | + | } | |
| 64 | + | update(c) | |
| 65 | + | } | |
| 66 | + | // Drain all queued updates before presenting one frame. | |
| 67 | + | drain: | |
| 68 | + | for { | |
| 69 | + | select { | |
| 70 | + | case s, ok := <-scenes: | |
| 71 | + | if !ok { | |
| 72 | + | scenes = nil | |
| 73 | + | } else { | |
| 74 | + | scene, haveScene = s, true | |
| 75 | + | } | |
| 76 | + | case c, ok := <-contents: | |
| 77 | + | if !ok { | |
| 78 | + | contents = nil | |
| 79 | + | } else { | |
| 80 | + | update(c) | |
| 81 | + | } | |
| 82 | + | default: | |
| 83 | + | break drain | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | if !haveScene { | |
| 87 | + | continue | |
| 88 | + | } | |
| 89 | + | start := time.Now() | |
| 90 | + | if err := r.Render(scene, surfaces); err != nil { | |
| 91 | + | return fmt.Errorf("render frame: %w", err) | |
| 92 | + | } | |
| 93 | + | frame++ | |
| 94 | + | if opts.ScreenshotDir != "" { | |
| 95 | + | if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil { | |
| 96 | + | return fmt.Errorf("screenshot: %w", err) | |
| 97 | + | } | |
| 98 | + | if err := writeLatest(opts.ScreenshotDir, r.Pixels()); err != nil { | |
| 99 | + | return fmt.Errorf("latest screenshot: %w", err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | opts.Log.Debug().Str("component", "render").Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func writePNG(path string, img *image.RGBA) error { | |
| 107 | + | f, err := os.Create(path) | |
| 108 | + | if err != nil { | |
| 109 | + | return err | |
| 110 | + | } | |
| 111 | + | if err := png.Encode(f, img); err != nil { | |
| 112 | + | _ = f.Close() | |
| 113 | + | return err | |
| 114 | + | } | |
| 115 | + | return f.Close() | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func writeLatest(dir string, img *image.RGBA) error { | |
| 119 | + | f, err := os.CreateTemp(dir, ".latest-*.png") | |
| 120 | + | if err != nil { | |
| 121 | + | return err | |
| 122 | + | } | |
| 123 | + | defer os.Remove(f.Name()) | |
| 124 | + | if err := png.Encode(f, img); err != nil { | |
| 125 | + | _ = f.Close() | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | if err := f.Close(); err != nil { | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | return os.Rename(f.Name(), filepath.Join(dir, "latest.png")) | |
| 132 | + | } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "image" | |||
| 7 | + | "image/png" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 13 | + | "github.com/bnema/zerowrap" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | type Renderer interface { | |||
| 17 | + | Render(ports.Scene, map[ports.WindowID]ports.SurfaceContent) error | |||
| 18 | + | Pixels() *image.RGBA | |||
| 19 | + | Close() | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type Options struct { | |||
| 23 | + | Width, Height int | |||
| 24 | + | ScreenshotDir string | |||
| 25 | + | Log zerowrap.Logger | |||
| 26 | + | NewRenderer func(w, h int) (Renderer, error) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func Run(ctx context.Context, opts Options, scenes <-chan ports.Scene, contents <-chan ports.SurfaceContent) error { | |||
| 30 | + | r, err := opts.NewRenderer(opts.Width, opts.Height) | |||
| 31 | + | if err != nil { | |||
| 32 | + | return fmt.Errorf("create renderer: %w", err) | |||
| 33 | + | } | |||
| 34 | + | defer r.Close() | |||
| 35 | + | surfaces := make(map[ports.WindowID]ports.SurfaceContent) | |||
| 36 | + | var scene ports.Scene | |||
| 37 | + | haveScene := false | |||
| 38 | + | frame := 0 | |||
| 39 | + | update := func(c ports.SurfaceContent) { | |||
| 40 | + | if c.Pixels == nil { | |||
| 41 | + | delete(surfaces, c.ID) | |||
| 42 | + | } else { | |||
| 43 | + | surfaces[c.ID] = c | |||
| 44 | + | } | |||
| 45 | + | } | |||
| 46 | + | for { | |||
| 47 | + | if ctx.Err() != nil { | |||
| 48 | + | return nil | |||
| 49 | + | } | |||
| 50 | + | select { | |||
| 51 | + | case <-ctx.Done(): | |||
| 52 | + | return nil | |||
| 53 | + | case s, ok := <-scenes: | |||
| 54 | + | if !ok { | |||
| 55 | + | scenes = nil | |||
| 56 | + | continue | |||
| 57 | + | } | |||
| 58 | + | scene, haveScene = s, true | |||
| 59 | + | case c, ok := <-contents: | |||
| 60 | + | if !ok { | |||
| 61 | + | contents = nil | |||
| 62 | + | continue | |||
| 63 | + | } | |||
| 64 | + | update(c) | |||
| 65 | + | } | |||
| 66 | + | // Drain all queued updates before presenting one frame. | |||
| 67 | + | drain: | |||
| 68 | + | for { | |||
| 69 | + | select { | |||
| 70 | + | case s, ok := <-scenes: | |||
| 71 | + | if !ok { | |||
| 72 | + | scenes = nil | |||
| 73 | + | } else { | |||
| 74 | + | scene, haveScene = s, true | |||
| 75 | + | } | |||
| 76 | + | case c, ok := <-contents: | |||
| 77 | + | if !ok { | |||
| 78 | + | contents = nil | |||
| 79 | + | } else { | |||
| 80 | + | update(c) | |||
| 81 | + | } | |||
| 82 | + | default: | |||
| 83 | + | break drain | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | if !haveScene { | |||
| 87 | + | continue | |||
| 88 | + | } | |||
| 89 | + | start := time.Now() | |||
| 90 | + | if err := r.Render(scene, surfaces); err != nil { | |||
| 91 | + | return fmt.Errorf("render frame: %w", err) | |||
| 92 | + | } | |||
| 93 | + | frame++ | |||
| 94 | + | if opts.ScreenshotDir != "" { | |||
| 95 | + | if err := writePNG(filepath.Join(opts.ScreenshotDir, fmt.Sprintf("frame-%06d.png", frame)), r.Pixels()); err != nil { | |||
| 96 | + | return fmt.Errorf("screenshot: %w", err) | |||
| 97 | + | } | |||
| 98 | + | if err := writeLatest(opts.ScreenshotDir, r.Pixels()); err != nil { | |||
| 99 | + | return fmt.Errorf("latest screenshot: %w", err) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | opts.Log.Debug().Str("component", "render").Int("frame", frame).Uint64("seq", scene.Seq).Int("windows", len(scene.Windows)).Dur("ms", time.Since(start)).Msg("frame") | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | func writePNG(path string, img *image.RGBA) error { | |||
| 107 | + | f, err := os.Create(path) | |||
| 108 | + | if err != nil { | |||
| 109 | + | return err | |||
| 110 | + | } | |||
| 111 | + | if err := png.Encode(f, img); err != nil { | |||
| 112 | + | _ = f.Close() | |||
| 113 | + | return err | |||
| 114 | + | } | |||
| 115 | + | return f.Close() | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func writeLatest(dir string, img *image.RGBA) error { | |||
| 119 | + | f, err := os.CreateTemp(dir, ".latest-*.png") | |||
| 120 | + | if err != nil { | |||
| 121 | + | return err | |||
| 122 | + | } | |||
| 123 | + | defer os.Remove(f.Name()) | |||
| 124 | + | if err := png.Encode(f, img); err != nil { | |||
| 125 | + | _ = f.Close() | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | if err := f.Close(); err != nil { | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | return os.Rename(f.Name(), filepath.Join(dir, "latest.png")) | |||
| 132 | + | } | |||
A internal/adapters/headless/output_test.go +117 -0
| @@ -0,0 +1,117 @@ | |||
| 1 | + | package headless | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "image" | |
| 7 | + | "image/png" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sync" | |
| 11 | + | "testing" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/nefertty/internal/ports" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | type fakeRenderer struct { | |
| 18 | + | mu sync.Mutex | |
| 19 | + | frames []ports.Scene | |
| 20 | + | contents []map[ports.WindowID]ports.SurfaceContent | |
| 21 | + | closed bool | |
| 22 | + | err error | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func (r *fakeRenderer) Render(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) error { | |
| 26 | + | r.mu.Lock() | |
| 27 | + | defer r.mu.Unlock() | |
| 28 | + | r.frames = append(r.frames, s) | |
| 29 | + | copyMap := make(map[ports.WindowID]ports.SurfaceContent) | |
| 30 | + | for id, v := range c { | |
| 31 | + | copyMap[id] = v | |
| 32 | + | } | |
| 33 | + | r.contents = append(r.contents, copyMap) | |
| 34 | + | return r.err | |
| 35 | + | } | |
| 36 | + | func (r *fakeRenderer) Pixels() *image.RGBA { return image.NewRGBA(image.Rect(0, 0, 2, 2)) } | |
| 37 | + | func (r *fakeRenderer) Close() { r.mu.Lock(); defer r.mu.Unlock(); r.closed = true } | |
| 38 | + | func TestRun(t *testing.T) { | |
| 39 | + | dir := t.TempDir() | |
| 40 | + | scenes := make(chan ports.Scene, 8) | |
| 41 | + | contents := make(chan ports.SurfaceContent, 8) | |
| 42 | + | r := new(fakeRenderer) | |
| 43 | + | contents <- ports.SurfaceContent{ID: 1, Pixels: []byte{1}} | |
| 44 | + | scenes <- ports.Scene{Seq: 1} | |
| 45 | + | scenes <- ports.Scene{Seq: 2} | |
| 46 | + | contents <- ports.SurfaceContent{ID: 2, Pixels: []byte{2}} | |
| 47 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 48 | + | done := make(chan error, 1) | |
| 49 | + | go func() { | |
| 50 | + | done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, contents) | |
| 51 | + | }() | |
| 52 | + | wait := func(n int) { | |
| 53 | + | t.Helper() | |
| 54 | + | deadline := time.After(3 * time.Second) | |
| 55 | + | for { | |
| 56 | + | r.mu.Lock() | |
| 57 | + | count := len(r.frames) | |
| 58 | + | r.mu.Unlock() | |
| 59 | + | if count >= n { | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | select { | |
| 63 | + | case <-deadline: | |
| 64 | + | t.Fatal("no frame") | |
| 65 | + | case <-time.After(time.Millisecond * 10): | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | wait(1) | |
| 70 | + | r.mu.Lock() | |
| 71 | + | if len(r.frames) != 1 || r.frames[0].Seq != 2 || len(r.contents[0]) != 2 { | |
| 72 | + | t.Errorf("coalescing: %+v %+v", r.frames, r.contents) | |
| 73 | + | } | |
| 74 | + | r.mu.Unlock() | |
| 75 | + | contents <- ports.SurfaceContent{ID: 1} | |
| 76 | + | wait(2) | |
| 77 | + | r.mu.Lock() | |
| 78 | + | if _, ok := r.contents[1][1]; ok { | |
| 79 | + | t.Error("content not deleted") | |
| 80 | + | } | |
| 81 | + | r.mu.Unlock() | |
| 82 | + | cancel() | |
| 83 | + | if err := <-done; err != nil { | |
| 84 | + | t.Fatal(err) | |
| 85 | + | } | |
| 86 | + | r.mu.Lock() | |
| 87 | + | if !r.closed { | |
| 88 | + | t.Error("renderer not closed") | |
| 89 | + | } | |
| 90 | + | r.mu.Unlock() | |
| 91 | + | for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} { | |
| 92 | + | f, err := os.Open(filepath.Join(dir, name)) | |
| 93 | + | if err != nil { | |
| 94 | + | t.Fatal(err) | |
| 95 | + | } | |
| 96 | + | _, err = png.Decode(f) | |
| 97 | + | _ = f.Close() | |
| 98 | + | if err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | func TestErrors(t *testing.T) { | |
| 104 | + | expected := errors.New("failure") | |
| 105 | + | scenes := make(chan ports.Scene, 1) | |
| 106 | + | scenes <- ports.Scene{} | |
| 107 | + | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return nil, expected }}, scenes, nil); !errors.Is(err, expected) { | |
| 108 | + | t.Fatal(err) | |
| 109 | + | } | |
| 110 | + | r := &fakeRenderer{err: expected} | |
| 111 | + | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, nil); !errors.Is(err, expected) { | |
| 112 | + | t.Fatal(err) | |
| 113 | + | } | |
| 114 | + | if !r.closed { | |
| 115 | + | t.Fatal("not closed") | |
| 116 | + | } | |
| 117 | + | } | |
| @@ -0,0 +1,117 @@ | |||||
| 1 | + | package headless | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "image" | |||
| 7 | + | "image/png" | |||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sync" | |||
| 11 | + | "testing" | |||
| 12 | + | "time" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/nefertty/internal/ports" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | type fakeRenderer struct { | |||
| 18 | + | mu sync.Mutex | |||
| 19 | + | frames []ports.Scene | |||
| 20 | + | contents []map[ports.WindowID]ports.SurfaceContent | |||
| 21 | + | closed bool | |||
| 22 | + | err error | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func (r *fakeRenderer) Render(s ports.Scene, c map[ports.WindowID]ports.SurfaceContent) error { | |||
| 26 | + | r.mu.Lock() | |||
| 27 | + | defer r.mu.Unlock() | |||
| 28 | + | r.frames = append(r.frames, s) | |||
| 29 | + | copyMap := make(map[ports.WindowID]ports.SurfaceContent) | |||
| 30 | + | for id, v := range c { | |||
| 31 | + | copyMap[id] = v | |||
| 32 | + | } | |||
| 33 | + | r.contents = append(r.contents, copyMap) | |||
| 34 | + | return r.err | |||
| 35 | + | } | |||
| 36 | + | func (r *fakeRenderer) Pixels() *image.RGBA { return image.NewRGBA(image.Rect(0, 0, 2, 2)) } | |||
| 37 | + | func (r *fakeRenderer) Close() { r.mu.Lock(); defer r.mu.Unlock(); r.closed = true } | |||
| 38 | + | func TestRun(t *testing.T) { | |||
| 39 | + | dir := t.TempDir() | |||
| 40 | + | scenes := make(chan ports.Scene, 8) | |||
| 41 | + | contents := make(chan ports.SurfaceContent, 8) | |||
| 42 | + | r := new(fakeRenderer) | |||
| 43 | + | contents <- ports.SurfaceContent{ID: 1, Pixels: []byte{1}} | |||
| 44 | + | scenes <- ports.Scene{Seq: 1} | |||
| 45 | + | scenes <- ports.Scene{Seq: 2} | |||
| 46 | + | contents <- ports.SurfaceContent{ID: 2, Pixels: []byte{2}} | |||
| 47 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 48 | + | done := make(chan error, 1) | |||
| 49 | + | go func() { | |||
| 50 | + | done <- Run(ctx, Options{Width: 2, Height: 2, ScreenshotDir: dir, NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, contents) | |||
| 51 | + | }() | |||
| 52 | + | wait := func(n int) { | |||
| 53 | + | t.Helper() | |||
| 54 | + | deadline := time.After(3 * time.Second) | |||
| 55 | + | for { | |||
| 56 | + | r.mu.Lock() | |||
| 57 | + | count := len(r.frames) | |||
| 58 | + | r.mu.Unlock() | |||
| 59 | + | if count >= n { | |||
| 60 | + | return | |||
| 61 | + | } | |||
| 62 | + | select { | |||
| 63 | + | case <-deadline: | |||
| 64 | + | t.Fatal("no frame") | |||
| 65 | + | case <-time.After(time.Millisecond * 10): | |||
| 66 | + | } | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | wait(1) | |||
| 70 | + | r.mu.Lock() | |||
| 71 | + | if len(r.frames) != 1 || r.frames[0].Seq != 2 || len(r.contents[0]) != 2 { | |||
| 72 | + | t.Errorf("coalescing: %+v %+v", r.frames, r.contents) | |||
| 73 | + | } | |||
| 74 | + | r.mu.Unlock() | |||
| 75 | + | contents <- ports.SurfaceContent{ID: 1} | |||
| 76 | + | wait(2) | |||
| 77 | + | r.mu.Lock() | |||
| 78 | + | if _, ok := r.contents[1][1]; ok { | |||
| 79 | + | t.Error("content not deleted") | |||
| 80 | + | } | |||
| 81 | + | r.mu.Unlock() | |||
| 82 | + | cancel() | |||
| 83 | + | if err := <-done; err != nil { | |||
| 84 | + | t.Fatal(err) | |||
| 85 | + | } | |||
| 86 | + | r.mu.Lock() | |||
| 87 | + | if !r.closed { | |||
| 88 | + | t.Error("renderer not closed") | |||
| 89 | + | } | |||
| 90 | + | r.mu.Unlock() | |||
| 91 | + | for _, name := range []string{"frame-000001.png", "frame-000002.png", "latest.png"} { | |||
| 92 | + | f, err := os.Open(filepath.Join(dir, name)) | |||
| 93 | + | if err != nil { | |||
| 94 | + | t.Fatal(err) | |||
| 95 | + | } | |||
| 96 | + | _, err = png.Decode(f) | |||
| 97 | + | _ = f.Close() | |||
| 98 | + | if err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | func TestErrors(t *testing.T) { | |||
| 104 | + | expected := errors.New("failure") | |||
| 105 | + | scenes := make(chan ports.Scene, 1) | |||
| 106 | + | scenes <- ports.Scene{} | |||
| 107 | + | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return nil, expected }}, scenes, nil); !errors.Is(err, expected) { | |||
| 108 | + | t.Fatal(err) | |||
| 109 | + | } | |||
| 110 | + | r := &fakeRenderer{err: expected} | |||
| 111 | + | if err := Run(context.Background(), Options{NewRenderer: func(int, int) (Renderer, error) { return r, nil }}, scenes, nil); !errors.Is(err, expected) { | |||
| 112 | + | t.Fatal(err) | |||
| 113 | + | } | |||
| 114 | + | if !r.closed { | |||
| 115 | + | t.Fatal("not closed") | |||
| 116 | + | } | |||
| 117 | + | } | |||
M internal/app/app.go +45 -9
| @@ -9,7 +9,9 @@ import ( | |||
| 9 | 9 | "sync" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/nefertty/internal/adapters/headless" | |
| 12 | 13 | "github.com/bnema/nefertty/internal/adapters/launcher" | |
| 14 | + | "github.com/bnema/nefertty/internal/adapters/vulkan" | |
| 13 | 15 | "github.com/bnema/nefertty/internal/adapters/wayland" | |
| 14 | 16 | "github.com/bnema/nefertty/internal/core" | |
| 15 | 17 | "github.com/bnema/nefertty/internal/logging" | |
| @@ -17,11 +19,12 @@ import ( | |||
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | type Options struct { | |
| 20 | - | Backend string | |
| 21 | - | Config ports.Config | |
| 22 | - | Timeout time.Duration | |
| 23 | - | NoTerminal bool | |
| 24 | - | testScenes chan<- ports.Scene | |
| 22 | + | Backend string | |
| 23 | + | Config ports.Config | |
| 24 | + | Timeout time.Duration | |
| 25 | + | NoTerminal bool | |
| 26 | + | ScreenshotDir string | |
| 27 | + | testScenes chan<- ports.Scene | |
| 25 | 28 | } | |
| 26 | 29 | ||
| 27 | 30 | func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | |
| @@ -47,6 +50,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 47 | 50 | spawn := make(chan ports.SpawnRequest, 32) | |
| 48 | 51 | scenes := make(chan ports.Scene, 1) | |
| 49 | 52 | configErrors := make(chan error, 8) | |
| 53 | + | renderScenes := make(chan ports.Scene, 1) | |
| 54 | + | contents := make(chan ports.SurfaceContent, 64) | |
| 55 | + | // TODO(merge): wire wayland Contents to contents. | |
| 50 | 56 | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | |
| 51 | 57 | c, err := core.New(opts.Config, ch) | |
| 52 | 58 | if err != nil { | |
| @@ -67,12 +73,23 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 67 | 73 | inject(input) | |
| 68 | 74 | } | |
| 69 | 75 | var workers sync.WaitGroup | |
| 70 | - | workers.Add(4) | |
| 71 | - | done := make(chan error, 3) | |
| 76 | + | workers.Add(5) | |
| 77 | + | done := make(chan error, 4) | |
| 72 | 78 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | |
| 73 | 79 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | |
| 74 | 80 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | |
| 75 | - | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes) }() | |
| 81 | + | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() | |
| 82 | + | ||
| 83 | + | go func() { | |
| 84 | + | defer workers.Done() | |
| 85 | + | done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) { | |
| 86 | + | r, err := vulkan.New(w, h) | |
| 87 | + | if err != nil { | |
| 88 | + | return nil, err | |
| 89 | + | } | |
| 90 | + | return vkRenderer{r}, nil | |
| 91 | + | }}, renderScenes, contents) | |
| 92 | + | }() | |
| 76 | 93 | ||
| 77 | 94 | var result error | |
| 78 | 95 | select { | |
| @@ -93,7 +110,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||
| 93 | 110 | return result | |
| 94 | 111 | } | |
| 95 | 112 | ||
| 96 | - | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene) { | |
| 113 | + | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { | |
| 97 | 114 | log := logging.For(ctx, "core") | |
| 98 | 115 | for { | |
| 99 | 116 | select { | |
| @@ -106,9 +123,28 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||
| 106 | 123 | default: | |
| 107 | 124 | } | |
| 108 | 125 | } | |
| 126 | + | select { | |
| 127 | + | case renderScenes <- s: | |
| 128 | + | default: | |
| 129 | + | select { | |
| 130 | + | case <-renderScenes: | |
| 131 | + | default: | |
| 132 | + | } | |
| 133 | + | select { | |
| 134 | + | case renderScenes <- s: | |
| 135 | + | default: | |
| 136 | + | } | |
| 137 | + | } | |
| 109 | 138 | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | |
| 110 | 139 | case err := <-configErrors: | |
| 111 | 140 | log.Debug().Err(err).Msg("config rejected") | |
| 112 | 141 | } | |
| 113 | 142 | } | |
| 114 | 143 | } | |
| 144 | + | ||
| 145 | + | // TODO(merge): drop once vulkan.Render takes contents. | |
| 146 | + | type vkRenderer struct{ *vulkan.Renderer } | |
| 147 | + | ||
| 148 | + | func (r vkRenderer) Render(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) error { | |
| 149 | + | return r.Renderer.Render(s) | |
| 150 | + | } | |
| @@ -9,7 +9,9 @@ import ( | |||||
| 9 | "sync" | 9 | "sync" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/bnema/nefertty/internal/adapters/headless" | |||
| 12 | "github.com/bnema/nefertty/internal/adapters/launcher" | 13 | "github.com/bnema/nefertty/internal/adapters/launcher" | ||
| 14 | + | "github.com/bnema/nefertty/internal/adapters/vulkan" | |||
| 13 | "github.com/bnema/nefertty/internal/adapters/wayland" | 15 | "github.com/bnema/nefertty/internal/adapters/wayland" | ||
| 14 | "github.com/bnema/nefertty/internal/core" | 16 | "github.com/bnema/nefertty/internal/core" | ||
| 15 | "github.com/bnema/nefertty/internal/logging" | 17 | "github.com/bnema/nefertty/internal/logging" | ||
| @@ -17,11 +19,12 @@ import ( | |||||
| 17 | ) | 19 | ) | ||
| 18 | 20 | ||||
| 19 | type Options struct { | 21 | type Options struct { | ||
| 20 | - | Backend string | 22 | + | Backend string |
| 21 | - | Config ports.Config | 23 | + | Config ports.Config |
| 22 | - | Timeout time.Duration | 24 | + | Timeout time.Duration |
| 23 | - | NoTerminal bool | 25 | + | NoTerminal bool |
| 24 | - | testScenes chan<- ports.Scene | 26 | + | ScreenshotDir string |
| 27 | + | testScenes chan<- ports.Scene | |||
| 25 | } | 28 | } | ||
| 26 | 29 | ||||
| 27 | func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | 30 | func Run(ctx context.Context, opts Options) error { return run(ctx, opts, nil) } | ||
| @@ -47,6 +50,9 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 47 | spawn := make(chan ports.SpawnRequest, 32) | 50 | spawn := make(chan ports.SpawnRequest, 32) | ||
| 48 | scenes := make(chan ports.Scene, 1) | 51 | scenes := make(chan ports.Scene, 1) | ||
| 49 | configErrors := make(chan error, 8) | 52 | configErrors := make(chan error, 8) | ||
| 53 | + | renderScenes := make(chan ports.Scene, 1) | |||
| 54 | + | contents := make(chan ports.SurfaceContent, 64) | |||
| 55 | + | // TODO(merge): wire wayland Contents to contents. | |||
| 50 | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | 56 | ch := core.Channels{Client: client, Input: input, Output: output, Config: config, Commands: commands, Spawn: spawn, Scenes: scenes, ConfigErrors: configErrors} | ||
| 51 | c, err := core.New(opts.Config, ch) | 57 | c, err := core.New(opts.Config, ch) | ||
| 52 | if err != nil { | 58 | if err != nil { | ||
| @@ -67,12 +73,23 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 67 | inject(input) | 73 | inject(input) | ||
| 68 | } | 74 | } | ||
| 69 | var workers sync.WaitGroup | 75 | var workers sync.WaitGroup | ||
| 70 | - | workers.Add(4) | 76 | + | workers.Add(5) |
| 71 | - | done := make(chan error, 3) | 77 | + | done := make(chan error, 4) |
| 72 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | 78 | go func() { defer workers.Done(); done <- server.Run(ctx) }() | ||
| 73 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | 79 | go func() { defer workers.Done(); done <- child.Run(ctx, spawn) }() | ||
| 74 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | 80 | go func() { defer workers.Done(); done <- c.Run(ctx) }() | ||
| 75 | - | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes) }() | 81 | + | go func() { defer workers.Done(); consumeScenes(ctx, scenes, configErrors, opts.testScenes, renderScenes) }() |
| 82 | + | ||||
| 83 | + | go func() { | |||
| 84 | + | defer workers.Done() | |||
| 85 | + | done <- headless.Run(ctx, headless.Options{Width: 1920, Height: 1080, ScreenshotDir: opts.ScreenshotDir, Log: logging.For(ctx, "render"), NewRenderer: func(w, h int) (headless.Renderer, error) { | |||
| 86 | + | r, err := vulkan.New(w, h) | |||
| 87 | + | if err != nil { | |||
| 88 | + | return nil, err | |||
| 89 | + | } | |||
| 90 | + | return vkRenderer{r}, nil | |||
| 91 | + | }}, renderScenes, contents) | |||
| 92 | + | }() | |||
| 76 | 93 | ||||
| 77 | var result error | 94 | var result error | ||
| 78 | select { | 95 | select { | ||
| @@ -93,7 +110,7 @@ func run(ctx context.Context, opts Options, inject func(chan<- ports.InputEvent) | |||||
| 93 | return result | 110 | return result | ||
| 94 | } | 111 | } | ||
| 95 | 112 | ||||
| 96 | - | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene) { | 113 | + | func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors <-chan error, tap chan<- ports.Scene, renderScenes chan ports.Scene) { |
| 97 | log := logging.For(ctx, "core") | 114 | log := logging.For(ctx, "core") | ||
| 98 | for { | 115 | for { | ||
| 99 | select { | 116 | select { | ||
| @@ -106,9 +123,28 @@ func consumeScenes(ctx context.Context, scenes <-chan ports.Scene, configErrors | |||||
| 106 | default: | 123 | default: | ||
| 107 | } | 124 | } | ||
| 108 | } | 125 | } | ||
| 126 | + | select { | |||
| 127 | + | case renderScenes <- s: | |||
| 128 | + | default: | |||
| 129 | + | select { | |||
| 130 | + | case <-renderScenes: | |||
| 131 | + | default: | |||
| 132 | + | } | |||
| 133 | + | select { | |||
| 134 | + | case renderScenes <- s: | |||
| 135 | + | default: | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 109 | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | 138 | log.Debug().Uint64("seq", s.Seq).Int("windows", len(s.Windows)).Msg("scene") | ||
| 110 | case err := <-configErrors: | 139 | case err := <-configErrors: | ||
| 111 | log.Debug().Err(err).Msg("config rejected") | 140 | log.Debug().Err(err).Msg("config rejected") | ||
| 112 | } | 141 | } | ||
| 113 | } | 142 | } | ||
| 114 | } | 143 | } | ||
| 144 | + | ||||
| 145 | + | // TODO(merge): drop once vulkan.Render takes contents. | |||
| 146 | + | type vkRenderer struct{ *vulkan.Renderer } | |||
| 147 | + | ||||
| 148 | + | func (r vkRenderer) Render(s ports.Scene, _ map[ports.WindowID]ports.SurfaceContent) error { | |||
| 149 | + | return r.Renderer.Render(s) | |||
| 150 | + | } | |||