detached f08960a

feat(core): integrate reviewed recording workflow

commit
f08960a639208009310b243af480bce440a88f63
author
brice · · 2026-09-30 08:32 UTC
committer
brice · · 2026-09-30 14:48 UTC
merge of
d8a7032 + dbee877 · diff against the first parent
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +1324 -0
A internal/adapters/cli/cli.go +209 -0
@@ -0,0 +1,209 @@
1+// Package cli parses the command line into capture options.
2+package cli
3+
4+import (
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// Command is the selected subcommand.
18+type Command string
19+
20+const (
21+ Outputs Command = "outputs"
22+ Screenshot Command = "screenshot"
23+ Record Command = "record"
24+ GUI Command = "gui"
25+)
26+
27+// DefaultFPS is the recording frame rate when -fps is not given.
28+const DefaultFPS = 30
29+
30+// ErrUsage wraps every command-line usage error.
31+var ErrUsage = errors.New("usage error")
32+
33+// Options is the parsed command line. Video is set only for Record.
34+type Options struct {
35+ Command Command
36+ Output string // output name; empty lets the caller choose
37+ Region ports.Region
38+ Path string
39+ Video ports.VideoSettings
40+ Duration time.Duration
41+ Debug bool
42+}
43+
44+// Usage describes the command line.
45+func Usage() string {
46+ return fmt.Sprintf(`Usage: nefercap [command] [flags]
47+
48+Commands:
49+ gui open the capture control panel (default)
50+ outputs list outputs
51+ screenshot save a PNG screenshot
52+ record record silent video until interrupted or target video length is reached
53+
54+Flags:
55+ -debug enable debug logging (all commands)
56+ -output NAME output name (screenshot, record)
57+ -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
58+ -file PATH new file to write, required (screenshot, record)
59+ -fps N frames per second, 1..%d (record, default %d)
60+ -size WxH scaled video size, both even, at most %d each (record, default source size)
61+ -duration D encoded video length such as 30s; 0 records until interrupted (record)
62+`, ports.MaxFPS, DefaultFPS, ports.MaxDimension)
63+}
64+
65+// Parse parses args (without the program name). Help requests print Usage to
66+// out and return flag.ErrHelp; other failures wrap ErrUsage.
67+func Parse(args []string, out io.Writer) (Options, error) {
68+ if out == nil {
69+ out = io.Discard
70+ }
71+ opts := Options{Command: GUI}
72+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
73+ switch cmd := Command(args[0]); cmd {
74+ case Outputs, Screenshot, Record, GUI:
75+ opts.Command = cmd
76+ args = args[1:]
77+ default:
78+ return Options{}, usageErr("unknown command %q", args[0])
79+ }
80+ }
81+
82+ fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
83+ fs.SetOutput(io.Discard)
84+ fs.BoolVar(&opts.Debug, "debug", false, "")
85+ var region, size string
86+ fps := DefaultFPS
87+ capture := opts.Command == Screenshot || opts.Command == Record
88+ if capture {
89+ fs.StringVar(&opts.Output, "output", "", "")
90+ fs.StringVar(&region, "region", "", "")
91+ fs.StringVar(&opts.Path, "file", "", "")
92+ }
93+ if opts.Command == Record {
94+ fs.IntVar(&fps, "fps", DefaultFPS, "")
95+ fs.StringVar(&size, "size", "", "")
96+ fs.DurationVar(&opts.Duration, "duration", 0, "")
97+ }
98+
99+ if err := fs.Parse(args); err != nil {
100+ if errors.Is(err, flag.ErrHelp) {
101+ _, _ = io.WriteString(out, Usage())
102+ return Options{}, flag.ErrHelp
103+ }
104+ return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
105+ }
106+ if fs.NArg() > 0 {
107+ return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
108+ }
109+ if !capture {
110+ return opts, nil
111+ }
112+
113+ if opts.Path == "" {
114+ return Options{}, usageErr("-file is required")
115+ }
116+ if region != "" {
117+ r, err := parseRegion(region)
118+ if err != nil {
119+ return Options{}, usageErr("invalid -region %q: %v", region, err)
120+ }
121+ opts.Region = r
122+ }
123+ if opts.Command == Record {
124+ if fps < 1 || fps > ports.MaxFPS {
125+ return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
126+ }
127+ opts.Video.FPS = fps
128+ if size != "" {
129+ w, h, err := parseSize(size)
130+ if err != nil {
131+ return Options{}, usageErr("invalid -size %q: %v", size, err)
132+ }
133+ if w%2 != 0 || h%2 != 0 {
134+ return Options{}, usageErr("invalid -size %q: width and height must be even", size)
135+ }
136+ if w > ports.MaxDimension || h > ports.MaxDimension || w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
137+ return Options{}, usageErr("invalid -size %q: exceeds frame limits", size)
138+ }
139+ opts.Video.Width, opts.Video.Height = w, h
140+ }
141+ if opts.Duration < 0 {
142+ return Options{}, usageErr("invalid -duration %s: must not be negative", opts.Duration)
143+ }
144+ }
145+ return opts, nil
146+}
147+
148+func usageErr(format string, a ...any) error {
149+ return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
150+}
151+
152+// parseRegion parses "X,Y,WxH" with nonnegative X,Y and positive W,H, all
153+// within int32.
154+func parseRegion(s string) (ports.Region, error) {
155+ xy, wh, ok := strings.Cut(s, "x")
156+ parts := strings.Split(xy, ",")
157+ if !ok || len(parts) != 3 {
158+ return ports.Region{}, errors.New("want X,Y,WxH")
159+ }
160+ h, err := parseInt(wh, 1)
161+ if err != nil {
162+ return ports.Region{}, err
163+ }
164+ x, err := parseInt(parts[0], 0)
165+ if err != nil {
166+ return ports.Region{}, err
167+ }
168+ y, err := parseInt(parts[1], 0)
169+ if err != nil {
170+ return ports.Region{}, err
171+ }
172+ w, err := parseInt(parts[2], 1)
173+ if err != nil {
174+ return ports.Region{}, err
175+ }
176+ if int64(x)+int64(w) > math.MaxInt32 || int64(y)+int64(h) > math.MaxInt32 {
177+ return ports.Region{}, errors.New("region exceeds int32 bounds")
178+ }
179+ return ports.Region{X: x, Y: y, Width: w, Height: h}, nil
180+}
181+
182+func parseSize(s string) (w, h int, err error) {
183+ ws, hs, ok := strings.Cut(s, "x")
184+ if !ok {
185+ return 0, 0, errors.New("want WxH")
186+ }
187+ if w, err = parseInt(ws, 1); err != nil {
188+ return 0, 0, err
189+ }
190+ if h, err = parseInt(hs, 1); err != nil {
191+ return 0, 0, err
192+ }
193+ return w, h, nil
194+}
195+
196+// parseInt accepts plain decimal digits only, at least min, at most MaxInt32.
197+func parseInt(s string, min int) (int, error) {
198+ if s == "" || strings.TrimLeft(s, "0123456789") != "" {
199+ return 0, fmt.Errorf("%q is not a decimal number", s)
200+ }
201+ v, err := strconv.ParseInt(s, 10, 32)
202+ if err != nil {
203+ return 0, fmt.Errorf("%q is out of range", s)
204+ }
205+ if int(v) < min {
206+ return 0, fmt.Errorf("%q must be at least %d", s, min)
207+ }
208+ return int(v), nil
209+}
@@ -0,0 +1,209 @@
1+// Package cli parses the command line into capture options.
2+package cli
3+
4+import (
5+ "errors"
6+ "flag"
7+ "fmt"
8+ "io"
9+ "math"
10+ "strconv"
11+ "strings"
12+ "time"
13+
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+// Command is the selected subcommand.
18+type Command string
19+
20+const (
21+ Outputs Command = "outputs"
22+ Screenshot Command = "screenshot"
23+ Record Command = "record"
24+ GUI Command = "gui"
25+)
26+
27+// DefaultFPS is the recording frame rate when -fps is not given.
28+const DefaultFPS = 30
29+
30+// ErrUsage wraps every command-line usage error.
31+var ErrUsage = errors.New("usage error")
32+
33+// Options is the parsed command line. Video is set only for Record.
34+type Options struct {
35+ Command Command
36+ Output string // output name; empty lets the caller choose
37+ Region ports.Region
38+ Path string
39+ Video ports.VideoSettings
40+ Duration time.Duration
41+ Debug bool
42+}
43+
44+// Usage describes the command line.
45+func Usage() string {
46+ return fmt.Sprintf(`Usage: nefercap [command] [flags]
47+
48+Commands:
49+ gui open the capture control panel (default)
50+ outputs list outputs
51+ screenshot save a PNG screenshot
52+ record record silent video until interrupted or target video length is reached
53+
54+Flags:
55+ -debug enable debug logging (all commands)
56+ -output NAME output name (screenshot, record)
57+ -region X,Y,WxH output-local logical region; default is the full output (screenshot, record)
58+ -file PATH new file to write, required (screenshot, record)
59+ -fps N frames per second, 1..%d (record, default %d)
60+ -size WxH scaled video size, both even, at most %d each (record, default source size)
61+ -duration D encoded video length such as 30s; 0 records until interrupted (record)
62+`, ports.MaxFPS, DefaultFPS, ports.MaxDimension)
63+}
64+
65+// Parse parses args (without the program name). Help requests print Usage to
66+// out and return flag.ErrHelp; other failures wrap ErrUsage.
67+func Parse(args []string, out io.Writer) (Options, error) {
68+ if out == nil {
69+ out = io.Discard
70+ }
71+ opts := Options{Command: GUI}
72+ if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
73+ switch cmd := Command(args[0]); cmd {
74+ case Outputs, Screenshot, Record, GUI:
75+ opts.Command = cmd
76+ args = args[1:]
77+ default:
78+ return Options{}, usageErr("unknown command %q", args[0])
79+ }
80+ }
81+
82+ fs := flag.NewFlagSet("nefercap "+string(opts.Command), flag.ContinueOnError)
83+ fs.SetOutput(io.Discard)
84+ fs.BoolVar(&opts.Debug, "debug", false, "")
85+ var region, size string
86+ fps := DefaultFPS
87+ capture := opts.Command == Screenshot || opts.Command == Record
88+ if capture {
89+ fs.StringVar(&opts.Output, "output", "", "")
90+ fs.StringVar(&region, "region", "", "")
91+ fs.StringVar(&opts.Path, "file", "", "")
92+ }
93+ if opts.Command == Record {
94+ fs.IntVar(&fps, "fps", DefaultFPS, "")
95+ fs.StringVar(&size, "size", "", "")
96+ fs.DurationVar(&opts.Duration, "duration", 0, "")
97+ }
98+
99+ if err := fs.Parse(args); err != nil {
100+ if errors.Is(err, flag.ErrHelp) {
101+ _, _ = io.WriteString(out, Usage())
102+ return Options{}, flag.ErrHelp
103+ }
104+ return Options{}, fmt.Errorf("%w: %w", ErrUsage, err)
105+ }
106+ if fs.NArg() > 0 {
107+ return Options{}, usageErr("unexpected argument %q", fs.Arg(0))
108+ }
109+ if !capture {
110+ return opts, nil
111+ }
112+
113+ if opts.Path == "" {
114+ return Options{}, usageErr("-file is required")
115+ }
116+ if region != "" {
117+ r, err := parseRegion(region)
118+ if err != nil {
119+ return Options{}, usageErr("invalid -region %q: %v", region, err)
120+ }
121+ opts.Region = r
122+ }
123+ if opts.Command == Record {
124+ if fps < 1 || fps > ports.MaxFPS {
125+ return Options{}, usageErr("invalid -fps %d: must be 1..%d", fps, ports.MaxFPS)
126+ }
127+ opts.Video.FPS = fps
128+ if size != "" {
129+ w, h, err := parseSize(size)
130+ if err != nil {
131+ return Options{}, usageErr("invalid -size %q: %v", size, err)
132+ }
133+ if w%2 != 0 || h%2 != 0 {
134+ return Options{}, usageErr("invalid -size %q: width and height must be even", size)
135+ }
136+ if w > ports.MaxDimension || h > ports.MaxDimension || w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
137+ return Options{}, usageErr("invalid -size %q: exceeds frame limits", size)
138+ }
139+ opts.Video.Width, opts.Video.Height = w, h
140+ }
141+ if opts.Duration < 0 {
142+ return Options{}, usageErr("invalid -duration %s: must not be negative", opts.Duration)
143+ }
144+ }
145+ return opts, nil
146+}
147+
148+func usageErr(format string, a ...any) error {
149+ return fmt.Errorf("%w: %s", ErrUsage, fmt.Sprintf(format, a...))
150+}
151+
152+// parseRegion parses "X,Y,WxH" with nonnegative X,Y and positive W,H, all
153+// within int32.
154+func parseRegion(s string) (ports.Region, error) {
155+ xy, wh, ok := strings.Cut(s, "x")
156+ parts := strings.Split(xy, ",")
157+ if !ok || len(parts) != 3 {
158+ return ports.Region{}, errors.New("want X,Y,WxH")
159+ }
160+ h, err := parseInt(wh, 1)
161+ if err != nil {
162+ return ports.Region{}, err
163+ }
164+ x, err := parseInt(parts[0], 0)
165+ if err != nil {
166+ return ports.Region{}, err
167+ }
168+ y, err := parseInt(parts[1], 0)
169+ if err != nil {
170+ return ports.Region{}, err
171+ }
172+ w, err := parseInt(parts[2], 1)
173+ if err != nil {
174+ return ports.Region{}, err
175+ }
176+ if int64(x)+int64(w) > math.MaxInt32 || int64(y)+int64(h) > math.MaxInt32 {
177+ return ports.Region{}, errors.New("region exceeds int32 bounds")
178+ }
179+ return ports.Region{X: x, Y: y, Width: w, Height: h}, nil
180+}
181+
182+func parseSize(s string) (w, h int, err error) {
183+ ws, hs, ok := strings.Cut(s, "x")
184+ if !ok {
185+ return 0, 0, errors.New("want WxH")
186+ }
187+ if w, err = parseInt(ws, 1); err != nil {
188+ return 0, 0, err
189+ }
190+ if h, err = parseInt(hs, 1); err != nil {
191+ return 0, 0, err
192+ }
193+ return w, h, nil
194+}
195+
196+// parseInt accepts plain decimal digits only, at least min, at most MaxInt32.
197+func parseInt(s string, min int) (int, error) {
198+ if s == "" || strings.TrimLeft(s, "0123456789") != "" {
199+ return 0, fmt.Errorf("%q is not a decimal number", s)
200+ }
201+ v, err := strconv.ParseInt(s, 10, 32)
202+ if err != nil {
203+ return 0, fmt.Errorf("%q is out of range", s)
204+ }
205+ if int(v) < min {
206+ return 0, fmt.Errorf("%q must be at least %d", s, min)
207+ }
208+ return int(v), nil
209+}
A internal/adapters/cli/cli_test.go +138 -0
@@ -0,0 +1,138 @@
1+package cli_test
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "flag"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/nefercap/internal/adapters/cli"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+func TestParseValid(t *testing.T) {
18+ cases := []struct {
19+ name string
20+ args []string
21+ want cli.Options
22+ }{
23+ {"default gui", nil, cli.Options{Command: cli.GUI}},
24+ {"gui debug", []string{"-debug"}, cli.Options{Command: cli.GUI, Debug: true}},
25+ {"explicit gui", []string{"gui"}, cli.Options{Command: cli.GUI}},
26+ {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}},
27+ {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}},
28+ {"screenshot", []string{"screenshot", "-file", "a.png"},
29+ cli.Options{Command: cli.Screenshot, Path: "a.png"}},
30+ {"screenshot all", []string{"screenshot", "-output", "DP-1", "-region", "10,20,300x400", "-file", "a.png", "-debug"},
31+ cli.Options{Command: cli.Screenshot, Output: "DP-1", Path: "a.png", Debug: true,
32+ Region: ports.Region{X: 10, Y: 20, Width: 300, Height: 400}}},
33+ {"record defaults", []string{"record", "-file", "a.mkv"},
34+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
35+ {"record all", []string{"record", "-file", "a.mkv", "-fps", "60", "-size", "1280x720", "-duration", "1m30s", "-output", "eDP-1", "-region", "0,0,640x480"},
36+ cli.Options{Command: cli.Record, Path: "a.mkv", Output: "eDP-1", Duration: 90 * time.Second,
37+ Region: ports.Region{Width: 640, Height: 480},
38+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
39+ {"record zero duration", []string{"record", "-file", "a.mkv", "-duration", "0"},
40+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
41+ {"record fps bounds", []string{"record", "-file", "a.mkv", "-fps", "120"},
42+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 120}}},
43+ {"double dash flag", []string{"record", "--file", "a.mkv", "--fps=1"},
44+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 1}}},
45+ }
46+ for _, tc := range cases {
47+ t.Run(tc.name, func(t *testing.T) {
48+ var out bytes.Buffer
49+ got, err := cli.Parse(tc.args, &out)
50+ require.NoError(t, err)
51+ assert.Equal(t, tc.want, got)
52+ assert.Empty(t, out.String())
53+ })
54+ }
55+}
56+
57+func TestParseUsageErrors(t *testing.T) {
58+ cases := map[string][]string{
59+ "unknown command": {"snap"},
60+ "positional after command": {"outputs", "extra"},
61+ "positional gui": {"extra"},
62+ "positional after flags": {"record", "-file", "a.mkv", "extra"},
63+ "unknown flag": {"-nope"},
64+ "unknown flag in command": {"outputs", "-nope"},
65+ "screenshot missing file": {"screenshot"},
66+ "record missing file": {"record", "-fps", "30"},
67+ "empty file": {"screenshot", "-file", ""},
68+ "capture flag on gui": {"-file", "a.png"},
69+ "capture flag on outputs": {"outputs", "-output", "DP-1"},
70+ "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"},
71+ "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"},
72+ "size on screenshot": {"screenshot", "-file", "a.png", "-size", "2x2"},
73+ "fps zero": {"record", "-file", "a", "-fps", "0"},
74+ "fps high": {"record", "-file", "a", "-fps", "121"},
75+ "fps text": {"record", "-file", "a", "-fps", "fast"},
76+ "size odd width": {"record", "-file", "a", "-size", "641x480"},
77+ "size odd height": {"record", "-file", "a", "-size", "640x481"},
78+ "size zero": {"record", "-file", "a", "-size", "0x0"},
79+ "size too large": {"record", "-file", "a", "-size", "16386x2"},
80+ "size frame too large": {"record", "-file", "a", "-size", "16384x16384"},
81+ "size missing height": {"record", "-file", "a", "-size", "640"},
82+ "size negative": {"record", "-file", "a", "-size", "-2x-2"},
83+ "size junk": {"record", "-file", "a", "-size", "axb"},
84+ "size signed": {"record", "-file", "a", "-size", "+2x2"},
85+ "duration negative": {"record", "-file", "a", "-duration", "-1s"},
86+ "duration bare number": {"record", "-file", "a", "-duration", "5"},
87+ "region empty parts": {"screenshot", "-file", "a", "-region", ",,x"},
88+ "region missing size": {"screenshot", "-file", "a", "-region", "1,2"},
89+ "region missing y": {"screenshot", "-file", "a", "-region", "1,10x10"},
90+ "region extra part": {"screenshot", "-file", "a", "-region", "1,2,3,10x10"},
91+ "region negative x": {"screenshot", "-file", "a", "-region", "-1,0,10x10"},
92+ "region negative y": {"screenshot", "-file", "a", "-region", "0,-1,10x10"},
93+ "region zero width": {"screenshot", "-file", "a", "-region", "0,0,0x10"},
94+ "region zero height": {"screenshot", "-file", "a", "-region", "0,0,10x0"},
95+ "region overflow": {"screenshot", "-file", "a", "-region", "0,0,99999999999x1"},
96+ "region sum overflow": {"screenshot", "-file", "a", "-region", "2147483647,0,1x1"},
97+ "region signed": {"screenshot", "-file", "a", "-region", "+1,0,10x10"},
98+ "region uppercase X": {"screenshot", "-file", "a", "-region", "0,0,10X10"},
99+ "region spaces": {"screenshot", "-file", "a", "-region", "0, 0, 10x10"},
100+ }
101+ for name, args := range cases {
102+ t.Run(name, func(t *testing.T) {
103+ var out bytes.Buffer
104+ got, err := cli.Parse(args, &out)
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, cli.ErrUsage)
107+ assert.NotErrorIs(t, err, flag.ErrHelp)
108+ assert.Equal(t, cli.Options{}, got)
109+ assert.Empty(t, out.String(), "errors are reported by the caller")
110+ })
111+ }
112+}
113+
114+func TestParseErrorNamesProblem(t *testing.T) {
115+ _, err := cli.Parse([]string{"record"}, nil)
116+ assert.ErrorContains(t, err, "-file is required")
117+ _, err = cli.Parse([]string{"snap"}, nil)
118+ assert.ErrorContains(t, err, `unknown command "snap"`)
119+ _, err = cli.Parse([]string{"screenshot", "-file", "a", "-region", "1,2"}, nil)
120+ assert.ErrorContains(t, err, "X,Y,WxH")
121+}
122+
123+func TestParseHelp(t *testing.T) {
124+ for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}} {
125+ var out bytes.Buffer
126+ got, err := cli.Parse(args, &out)
127+ assert.True(t, errors.Is(err, flag.ErrHelp), args)
128+ assert.Equal(t, cli.Options{}, got)
129+ assert.Equal(t, cli.Usage(), out.String())
130+ }
131+}
132+
133+func TestUsageMentionsEverything(t *testing.T) {
134+ u := cli.Usage()
135+ for _, s := range []string{"gui", "outputs", "screenshot", "record", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
136+ assert.Contains(t, u, s)
137+ }
138+}
@@ -0,0 +1,138 @@
1+package cli_test
2+
3+import (
4+ "bytes"
5+ "errors"
6+ "flag"
7+ "testing"
8+ "time"
9+
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/nefercap/internal/adapters/cli"
14+ "github.com/bnema/nefercap/internal/ports"
15+)
16+
17+func TestParseValid(t *testing.T) {
18+ cases := []struct {
19+ name string
20+ args []string
21+ want cli.Options
22+ }{
23+ {"default gui", nil, cli.Options{Command: cli.GUI}},
24+ {"gui debug", []string{"-debug"}, cli.Options{Command: cli.GUI, Debug: true}},
25+ {"explicit gui", []string{"gui"}, cli.Options{Command: cli.GUI}},
26+ {"outputs", []string{"outputs"}, cli.Options{Command: cli.Outputs}},
27+ {"outputs debug", []string{"outputs", "-debug"}, cli.Options{Command: cli.Outputs, Debug: true}},
28+ {"screenshot", []string{"screenshot", "-file", "a.png"},
29+ cli.Options{Command: cli.Screenshot, Path: "a.png"}},
30+ {"screenshot all", []string{"screenshot", "-output", "DP-1", "-region", "10,20,300x400", "-file", "a.png", "-debug"},
31+ cli.Options{Command: cli.Screenshot, Output: "DP-1", Path: "a.png", Debug: true,
32+ Region: ports.Region{X: 10, Y: 20, Width: 300, Height: 400}}},
33+ {"record defaults", []string{"record", "-file", "a.mkv"},
34+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
35+ {"record all", []string{"record", "-file", "a.mkv", "-fps", "60", "-size", "1280x720", "-duration", "1m30s", "-output", "eDP-1", "-region", "0,0,640x480"},
36+ cli.Options{Command: cli.Record, Path: "a.mkv", Output: "eDP-1", Duration: 90 * time.Second,
37+ Region: ports.Region{Width: 640, Height: 480},
38+ Video: ports.VideoSettings{FPS: 60, Width: 1280, Height: 720}}},
39+ {"record zero duration", []string{"record", "-file", "a.mkv", "-duration", "0"},
40+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 30}}},
41+ {"record fps bounds", []string{"record", "-file", "a.mkv", "-fps", "120"},
42+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 120}}},
43+ {"double dash flag", []string{"record", "--file", "a.mkv", "--fps=1"},
44+ cli.Options{Command: cli.Record, Path: "a.mkv", Video: ports.VideoSettings{FPS: 1}}},
45+ }
46+ for _, tc := range cases {
47+ t.Run(tc.name, func(t *testing.T) {
48+ var out bytes.Buffer
49+ got, err := cli.Parse(tc.args, &out)
50+ require.NoError(t, err)
51+ assert.Equal(t, tc.want, got)
52+ assert.Empty(t, out.String())
53+ })
54+ }
55+}
56+
57+func TestParseUsageErrors(t *testing.T) {
58+ cases := map[string][]string{
59+ "unknown command": {"snap"},
60+ "positional after command": {"outputs", "extra"},
61+ "positional gui": {"extra"},
62+ "positional after flags": {"record", "-file", "a.mkv", "extra"},
63+ "unknown flag": {"-nope"},
64+ "unknown flag in command": {"outputs", "-nope"},
65+ "screenshot missing file": {"screenshot"},
66+ "record missing file": {"record", "-fps", "30"},
67+ "empty file": {"screenshot", "-file", ""},
68+ "capture flag on gui": {"-file", "a.png"},
69+ "capture flag on outputs": {"outputs", "-output", "DP-1"},
70+ "fps on screenshot": {"screenshot", "-file", "a.png", "-fps", "30"},
71+ "duration on screenshot": {"screenshot", "-file", "a.png", "-duration", "1s"},
72+ "size on screenshot": {"screenshot", "-file", "a.png", "-size", "2x2"},
73+ "fps zero": {"record", "-file", "a", "-fps", "0"},
74+ "fps high": {"record", "-file", "a", "-fps", "121"},
75+ "fps text": {"record", "-file", "a", "-fps", "fast"},
76+ "size odd width": {"record", "-file", "a", "-size", "641x480"},
77+ "size odd height": {"record", "-file", "a", "-size", "640x481"},
78+ "size zero": {"record", "-file", "a", "-size", "0x0"},
79+ "size too large": {"record", "-file", "a", "-size", "16386x2"},
80+ "size frame too large": {"record", "-file", "a", "-size", "16384x16384"},
81+ "size missing height": {"record", "-file", "a", "-size", "640"},
82+ "size negative": {"record", "-file", "a", "-size", "-2x-2"},
83+ "size junk": {"record", "-file", "a", "-size", "axb"},
84+ "size signed": {"record", "-file", "a", "-size", "+2x2"},
85+ "duration negative": {"record", "-file", "a", "-duration", "-1s"},
86+ "duration bare number": {"record", "-file", "a", "-duration", "5"},
87+ "region empty parts": {"screenshot", "-file", "a", "-region", ",,x"},
88+ "region missing size": {"screenshot", "-file", "a", "-region", "1,2"},
89+ "region missing y": {"screenshot", "-file", "a", "-region", "1,10x10"},
90+ "region extra part": {"screenshot", "-file", "a", "-region", "1,2,3,10x10"},
91+ "region negative x": {"screenshot", "-file", "a", "-region", "-1,0,10x10"},
92+ "region negative y": {"screenshot", "-file", "a", "-region", "0,-1,10x10"},
93+ "region zero width": {"screenshot", "-file", "a", "-region", "0,0,0x10"},
94+ "region zero height": {"screenshot", "-file", "a", "-region", "0,0,10x0"},
95+ "region overflow": {"screenshot", "-file", "a", "-region", "0,0,99999999999x1"},
96+ "region sum overflow": {"screenshot", "-file", "a", "-region", "2147483647,0,1x1"},
97+ "region signed": {"screenshot", "-file", "a", "-region", "+1,0,10x10"},
98+ "region uppercase X": {"screenshot", "-file", "a", "-region", "0,0,10X10"},
99+ "region spaces": {"screenshot", "-file", "a", "-region", "0, 0, 10x10"},
100+ }
101+ for name, args := range cases {
102+ t.Run(name, func(t *testing.T) {
103+ var out bytes.Buffer
104+ got, err := cli.Parse(args, &out)
105+ require.Error(t, err)
106+ assert.ErrorIs(t, err, cli.ErrUsage)
107+ assert.NotErrorIs(t, err, flag.ErrHelp)
108+ assert.Equal(t, cli.Options{}, got)
109+ assert.Empty(t, out.String(), "errors are reported by the caller")
110+ })
111+ }
112+}
113+
114+func TestParseErrorNamesProblem(t *testing.T) {
115+ _, err := cli.Parse([]string{"record"}, nil)
116+ assert.ErrorContains(t, err, "-file is required")
117+ _, err = cli.Parse([]string{"snap"}, nil)
118+ assert.ErrorContains(t, err, `unknown command "snap"`)
119+ _, err = cli.Parse([]string{"screenshot", "-file", "a", "-region", "1,2"}, nil)
120+ assert.ErrorContains(t, err, "X,Y,WxH")
121+}
122+
123+func TestParseHelp(t *testing.T) {
124+ for _, args := range [][]string{{"-h"}, {"--help"}, {"-help"}, {"record", "--help"}, {"outputs", "-h"}} {
125+ var out bytes.Buffer
126+ got, err := cli.Parse(args, &out)
127+ assert.True(t, errors.Is(err, flag.ErrHelp), args)
128+ assert.Equal(t, cli.Options{}, got)
129+ assert.Equal(t, cli.Usage(), out.String())
130+ }
131+}
132+
133+func TestUsageMentionsEverything(t *testing.T) {
134+ u := cli.Usage()
135+ for _, s := range []string{"gui", "outputs", "screenshot", "record", "-output", "-region", "-file", "-fps", "-size", "-duration", "-debug"} {
136+ assert.Contains(t, u, s)
137+ }
138+}
A internal/core/service.go +329 -0
@@ -0,0 +1,329 @@
1+// Package core implements the capture workflow over ports only.
2+package core
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "math"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// ErrInvalidSelection reports a selection rejected before any port is used.
15+var ErrInvalidSelection = errors.New("invalid capture selection")
16+
17+// Service runs one capture selection. It does not own the Source lifetime:
18+// the caller closes the Source after Run returns.
19+type Service struct {
20+ source ports.Source
21+ png ports.ScreenshotWriter
22+ video ports.VideoWriter
23+}
24+
25+// New wires the capture workflow to its ports.
26+func New(source ports.Source, png ports.ScreenshotWriter, video ports.VideoWriter) *Service {
27+ return &Service{source: source, png: png, video: video}
28+}
29+
30+// Run validates selection and then takes one screenshot or records silent
31+// fixed-rate video. Captures and writes are sequential, so at most one is in
32+// flight, and frame storage is reused without copying.
33+//
34+// A timed recording (Duration > 0) encodes exactly ceil(Duration*FPS) frames,
35+// so its encoded length is Duration rounded up to a frame. Wall time may run
36+// longer when Capture or Write is slow: missed slots repeat the current frame
37+// (at most two repeats per fresh Capture) and the schedule then moves
38+// forward, so a slow encoder lengthens the run instead of starving Capture.
39+// The clock starts after Start and the first Write succeed.
40+//
41+// Cancelling ctx stops recording at the last frame already written and
42+// finalizes through VideoWriter.Close. Nothing is written after cancellation.
43+// Source and VideoWriter must report cancellation as ctx.Err() (or an error
44+// wrapping only it); a failure joined with a context error, or a custom
45+// cancellation cause, is treated as a real failure and returned.
46+// A failure before the first frame is written aborts the file instead.
47+func (s *Service) Run(ctx context.Context, selection ports.Selection) error {
48+ if err := validateSelection(selection); err != nil {
49+ return err
50+ }
51+ if err := ctx.Err(); err != nil {
52+ return err
53+ }
54+ if selection.Mode == ports.Screenshot {
55+ return s.screenshot(ctx, selection)
56+ }
57+ return s.record(ctx, selection)
58+}
59+
60+func (s *Service) screenshot(ctx context.Context, sel ports.Selection) error {
61+ frame, err := s.source.Capture(ctx, sel.Target)
62+ if err != nil {
63+ return fmt.Errorf("capture screenshot: %w", err)
64+ }
65+ if err := frame.Validate(); err != nil {
66+ return fmt.Errorf("captured frame: %w", err)
67+ }
68+ if err := s.png.Save(ctx, frame, sel.Path); err != nil {
69+ return fmt.Errorf("save screenshot: %w", err)
70+ }
71+ return nil
72+}
73+
74+func (s *Service) record(ctx context.Context, sel ports.Selection) error {
75+ frame, err := s.source.Capture(ctx, sel.Target)
76+ if err != nil {
77+ return fmt.Errorf("capture first frame: %w", err)
78+ }
79+ if err := frame.Validate(); err != nil {
80+ return fmt.Errorf("captured frame: %w", err)
81+ }
82+ if err := s.video.Start(ctx, frame, sel.Path, sel.Video); err != nil {
83+ return s.abort(fmt.Errorf("start video: %w", err))
84+ }
85+ if err := s.video.Write(ctx, frame); err != nil {
86+ // Nothing was recorded, even when the cause is cancellation: discard
87+ // the file instead of finalizing an empty recording.
88+ return s.abort(fmt.Errorf("write first frame: %w", err))
89+ }
90+
91+ loopErr := s.recordLoop(ctx, frame, sel)
92+ // Graceful finalize on every exit after the first frame; the writer bounds it.
93+ if closeErr := s.video.Close(); closeErr != nil {
94+ return errors.Join(loopErr, fmt.Errorf("finalize video: %w", closeErr))
95+ }
96+ return loopErr
97+}
98+
99+func (s *Service) abort(err error) error {
100+ if abortErr := s.video.Abort(); abortErr != nil {
101+ return errors.Join(err, fmt.Errorf("abort video: %w", abortErr))
102+ }
103+ return err
104+}
105+
106+// maxRepeatSlots bounds how many missed slots repeat the current frame before
107+// the next fresh Capture (so at most three identical consecutive writes). A
108+// larger lag moves the schedule forward instead, so encoder backpressure
109+// becomes wall-time overrun rather than a stale-frame spiral.
110+const maxRepeatSlots = 2
111+
112+// schedule maps elapsed time since the first frame to fixed-rate frame slots.
113+// Slot n is due at n/fps. total is the slot count of a timed recording, or 0
114+// when recording until cancellation.
115+type schedule struct {
116+ start time.Time
117+ fps int64
118+ total int64
119+}
120+
121+func newSchedule(start time.Time, sel ports.Selection) schedule {
122+ sc := schedule{start: start, fps: int64(sel.Video.FPS)}
123+ if sel.Duration > 0 {
124+ // ceil(duration*fps) split into whole seconds and remainder so the
125+ // arithmetic cannot overflow for any positive time.Duration.
126+ secs, rem := int64(sel.Duration/time.Second), int64(sel.Duration%time.Second)
127+ sc.total = secs*sc.fps + (rem*sc.fps+int64(time.Second)-1)/int64(time.Second)
128+ }
129+ return sc
130+}
131+
132+// due returns the offset at which slot n starts.
133+func (sc schedule) due(n int64) time.Duration {
134+ q, r := n/sc.fps, n%sc.fps
135+ return time.Duration(q)*time.Second + time.Duration(r)*time.Second/time.Duration(sc.fps)
136+}
137+
138+// slot returns the slot containing now, unclamped.
139+func (sc schedule) slot() int64 {
140+ elapsed := time.Since(sc.start)
141+ secs, rem := int64(elapsed/time.Second), int64(elapsed%time.Second)
142+ return secs*sc.fps + rem*sc.fps/int64(time.Second)
143+}
144+
145+// catchUp limits the lag of now behind slot next to maxRepeatSlots by moving
146+// the schedule start forward. It returns the resulting current slot (>= next).
147+func (sc *schedule) catchUp(next int64) int64 {
148+ cur := max(sc.slot(), next)
149+ if cur-next > maxRepeatSlots {
150+ sc.start = sc.start.Add(time.Since(sc.start) - sc.due(next+maxRepeatSlots))
151+ cur = max(sc.slot(), next)
152+ }
153+ return cur
154+}
155+
156+// writeTimed writes frame and returns how long the encoder took.
157+func (s *Service) writeTimed(ctx context.Context, frame ports.Frame) (time.Duration, error) {
158+ began := time.Now()
159+ err := s.video.Write(ctx, frame)
160+ return time.Since(began), err
161+}
162+
163+// recordLoop fills slots 1.. after the already written first frame. A slot
164+// missed because Capture ran long reuses the current frame, which is still
165+// valid until the next Capture, so the encoded duration stays exact even when
166+// wall time overruns. Encoder time does not cause repeats: the full duration
167+// of a repeated Write, and the part of a fresh Write beyond one frame
168+// interval, is added to the schedule start, so only Capture delay is
169+// duplicated. catchUp is a backstop that keeps at most maxRepeatSlots repeats
170+// per fresh Capture. A timed recording ends after ceil(duration*fps)
171+// frames, so a duration shorter than one frame interval encodes one frame and
172+// returns without waiting. Cancellation ends the recording at the last frame
173+// already written: nothing is written afterwards, because the writer is
174+// cancelled with ctx and storage may be invalidated by an interrupted Capture.
175+// Exact frame counts therefore hold only for timed recordings that are not
176+// cancelled. Slot n < total is due before the duration, so due cannot overflow.
177+func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.Selection) error {
178+ sc := newSchedule(time.Now(), sel)
179+ timer := time.NewTimer(time.Hour)
180+ defer timer.Stop()
181+ timer.Stop()
182+
183+ next := int64(1)
184+ for sc.total == 0 || next < sc.total {
185+ if wait := sc.due(next) - time.Since(sc.start); wait > 0 {
186+ timer.Reset(wait)
187+ select {
188+ case <-ctx.Done():
189+ return nil
190+ case <-timer.C:
191+ }
192+ }
193+ if ctx.Err() != nil {
194+ return nil
195+ }
196+
197+ cur := sc.catchUp(next)
198+ past := sc.total > 0 && cur >= sc.total
199+ if past {
200+ cur = sc.total - 1
201+ }
202+ // Slots already missed repeat the frame before it is invalidated.
203+ last := cur - 1
204+ if past {
205+ last = cur // window over: no capture for the final slot
206+ }
207+ for ; next <= last; next++ {
208+ if ctx.Err() != nil {
209+ return nil
210+ }
211+ took, err := s.writeTimed(ctx, frame)
212+ if err != nil {
213+ return writeResult(ctx, err)
214+ }
215+ sc.start = sc.start.Add(took) // repeated encoder time is not lag
216+ }
217+ if past {
218+ return nil
219+ }
220+
221+ var err error
222+ if frame, err = s.source.Capture(ctx, sel.Target); err != nil {
223+ if stopped(ctx, err) {
224+ return nil
225+ }
226+ return fmt.Errorf("capture frame: %w", err)
227+ }
228+ if err := frame.Validate(); err != nil {
229+ return fmt.Errorf("captured frame: %w", err)
230+ }
231+ if ctx.Err() != nil {
232+ return nil
233+ }
234+ took, err := s.writeTimed(ctx, frame)
235+ if err != nil {
236+ return writeResult(ctx, err)
237+ }
238+ // Only the part of a fresh Write beyond its own slot is delay.
239+ sc.start = sc.start.Add(max(0, took-sc.due(1)))
240+ next++
241+ }
242+ return nil
243+}
244+
245+func writeResult(ctx context.Context, err error) error {
246+ if stopped(ctx, err) {
247+ return nil
248+ }
249+ return fmt.Errorf("write frame: %w", err)
250+}
251+
252+// stopped reports whether the run is ending and err consists only of context
253+// cancellation or deadline leaves. A failure joined with cancellation is real.
254+func stopped(ctx context.Context, err error) bool {
255+ return ctx.Err() != nil && onlyContext(err)
256+}
257+
258+func onlyContext(err error) bool {
259+ switch e := err.(type) {
260+ case nil:
261+ return false
262+ case interface{ Unwrap() []error }:
263+ children := e.Unwrap()
264+ for _, c := range children {
265+ if !onlyContext(c) {
266+ return false
267+ }
268+ }
269+ return len(children) > 0
270+ case interface{ Unwrap() error }:
271+ return err == context.Canceled || err == context.DeadlineExceeded || onlyContext(e.Unwrap())
272+ default:
273+ return err == context.Canceled || err == context.DeadlineExceeded
274+ }
275+}
276+
277+func validateSelection(sel ports.Selection) error {
278+ switch sel.Mode {
279+ case ports.Screenshot, ports.Record:
280+ default:
281+ return fmt.Errorf("%w: unknown mode %q", ErrInvalidSelection, sel.Mode)
282+ }
283+ if sel.Target.OutputID == 0 {
284+ return fmt.Errorf("%w: output id is zero", ErrInvalidSelection)
285+ }
286+ if err := validateRegion(sel.Target.Region); err != nil {
287+ return err
288+ }
289+ if sel.Path == "" {
290+ return fmt.Errorf("%w: empty path", ErrInvalidSelection)
291+ }
292+ if sel.Duration < 0 {
293+ return fmt.Errorf("%w: negative duration %s", ErrInvalidSelection, sel.Duration)
294+ }
295+ if sel.Mode == ports.Record {
296+ return validateVideo(sel.Video)
297+ }
298+ return nil
299+}
300+
301+// validateRegion accepts the zero region (full output) or a region with
302+// positive size and nonnegative origin that fits in int32.
303+func validateRegion(r ports.Region) error {
304+ if r == (ports.Region{}) {
305+ return nil
306+ }
307+ x, y, w, h := int64(r.X), int64(r.Y), int64(r.Width), int64(r.Height)
308+ if x < 0 || y < 0 || w < 1 || h < 1 ||
309+ x > math.MaxInt32 || y > math.MaxInt32 || w > math.MaxInt32 || h > math.MaxInt32 ||
310+ x+w > math.MaxInt32 || y+h > math.MaxInt32 {
311+ return fmt.Errorf("%w: %w: %+v", ErrInvalidSelection, ports.ErrInvalidRegion, r)
312+ }
313+ return nil
314+}
315+
316+func validateVideo(v ports.VideoSettings) error {
317+ if v.FPS < 1 || v.FPS > ports.MaxFPS {
318+ return fmt.Errorf("%w: fps %d outside 1..%d", ErrInvalidSelection, v.FPS, ports.MaxFPS)
319+ }
320+ if v.Width == 0 && v.Height == 0 {
321+ return nil
322+ }
323+ w, h := v.Width, v.Height
324+ if w < 1 || h < 1 || w > ports.MaxDimension || h > ports.MaxDimension || w%2 != 0 || h%2 != 0 ||
325+ w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
326+ return fmt.Errorf("%w: invalid output size %dx%d", ErrInvalidSelection, w, h)
327+ }
328+ return nil
329+}
@@ -0,0 +1,329 @@
1+// Package core implements the capture workflow over ports only.
2+package core
3+
4+import (
5+ "context"
6+ "errors"
7+ "fmt"
8+ "math"
9+ "time"
10+
11+ "github.com/bnema/nefercap/internal/ports"
12+)
13+
14+// ErrInvalidSelection reports a selection rejected before any port is used.
15+var ErrInvalidSelection = errors.New("invalid capture selection")
16+
17+// Service runs one capture selection. It does not own the Source lifetime:
18+// the caller closes the Source after Run returns.
19+type Service struct {
20+ source ports.Source
21+ png ports.ScreenshotWriter
22+ video ports.VideoWriter
23+}
24+
25+// New wires the capture workflow to its ports.
26+func New(source ports.Source, png ports.ScreenshotWriter, video ports.VideoWriter) *Service {
27+ return &Service{source: source, png: png, video: video}
28+}
29+
30+// Run validates selection and then takes one screenshot or records silent
31+// fixed-rate video. Captures and writes are sequential, so at most one is in
32+// flight, and frame storage is reused without copying.
33+//
34+// A timed recording (Duration > 0) encodes exactly ceil(Duration*FPS) frames,
35+// so its encoded length is Duration rounded up to a frame. Wall time may run
36+// longer when Capture or Write is slow: missed slots repeat the current frame
37+// (at most two repeats per fresh Capture) and the schedule then moves
38+// forward, so a slow encoder lengthens the run instead of starving Capture.
39+// The clock starts after Start and the first Write succeed.
40+//
41+// Cancelling ctx stops recording at the last frame already written and
42+// finalizes through VideoWriter.Close. Nothing is written after cancellation.
43+// Source and VideoWriter must report cancellation as ctx.Err() (or an error
44+// wrapping only it); a failure joined with a context error, or a custom
45+// cancellation cause, is treated as a real failure and returned.
46+// A failure before the first frame is written aborts the file instead.
47+func (s *Service) Run(ctx context.Context, selection ports.Selection) error {
48+ if err := validateSelection(selection); err != nil {
49+ return err
50+ }
51+ if err := ctx.Err(); err != nil {
52+ return err
53+ }
54+ if selection.Mode == ports.Screenshot {
55+ return s.screenshot(ctx, selection)
56+ }
57+ return s.record(ctx, selection)
58+}
59+
60+func (s *Service) screenshot(ctx context.Context, sel ports.Selection) error {
61+ frame, err := s.source.Capture(ctx, sel.Target)
62+ if err != nil {
63+ return fmt.Errorf("capture screenshot: %w", err)
64+ }
65+ if err := frame.Validate(); err != nil {
66+ return fmt.Errorf("captured frame: %w", err)
67+ }
68+ if err := s.png.Save(ctx, frame, sel.Path); err != nil {
69+ return fmt.Errorf("save screenshot: %w", err)
70+ }
71+ return nil
72+}
73+
74+func (s *Service) record(ctx context.Context, sel ports.Selection) error {
75+ frame, err := s.source.Capture(ctx, sel.Target)
76+ if err != nil {
77+ return fmt.Errorf("capture first frame: %w", err)
78+ }
79+ if err := frame.Validate(); err != nil {
80+ return fmt.Errorf("captured frame: %w", err)
81+ }
82+ if err := s.video.Start(ctx, frame, sel.Path, sel.Video); err != nil {
83+ return s.abort(fmt.Errorf("start video: %w", err))
84+ }
85+ if err := s.video.Write(ctx, frame); err != nil {
86+ // Nothing was recorded, even when the cause is cancellation: discard
87+ // the file instead of finalizing an empty recording.
88+ return s.abort(fmt.Errorf("write first frame: %w", err))
89+ }
90+
91+ loopErr := s.recordLoop(ctx, frame, sel)
92+ // Graceful finalize on every exit after the first frame; the writer bounds it.
93+ if closeErr := s.video.Close(); closeErr != nil {
94+ return errors.Join(loopErr, fmt.Errorf("finalize video: %w", closeErr))
95+ }
96+ return loopErr
97+}
98+
99+func (s *Service) abort(err error) error {
100+ if abortErr := s.video.Abort(); abortErr != nil {
101+ return errors.Join(err, fmt.Errorf("abort video: %w", abortErr))
102+ }
103+ return err
104+}
105+
106+// maxRepeatSlots bounds how many missed slots repeat the current frame before
107+// the next fresh Capture (so at most three identical consecutive writes). A
108+// larger lag moves the schedule forward instead, so encoder backpressure
109+// becomes wall-time overrun rather than a stale-frame spiral.
110+const maxRepeatSlots = 2
111+
112+// schedule maps elapsed time since the first frame to fixed-rate frame slots.
113+// Slot n is due at n/fps. total is the slot count of a timed recording, or 0
114+// when recording until cancellation.
115+type schedule struct {
116+ start time.Time
117+ fps int64
118+ total int64
119+}
120+
121+func newSchedule(start time.Time, sel ports.Selection) schedule {
122+ sc := schedule{start: start, fps: int64(sel.Video.FPS)}
123+ if sel.Duration > 0 {
124+ // ceil(duration*fps) split into whole seconds and remainder so the
125+ // arithmetic cannot overflow for any positive time.Duration.
126+ secs, rem := int64(sel.Duration/time.Second), int64(sel.Duration%time.Second)
127+ sc.total = secs*sc.fps + (rem*sc.fps+int64(time.Second)-1)/int64(time.Second)
128+ }
129+ return sc
130+}
131+
132+// due returns the offset at which slot n starts.
133+func (sc schedule) due(n int64) time.Duration {
134+ q, r := n/sc.fps, n%sc.fps
135+ return time.Duration(q)*time.Second + time.Duration(r)*time.Second/time.Duration(sc.fps)
136+}
137+
138+// slot returns the slot containing now, unclamped.
139+func (sc schedule) slot() int64 {
140+ elapsed := time.Since(sc.start)
141+ secs, rem := int64(elapsed/time.Second), int64(elapsed%time.Second)
142+ return secs*sc.fps + rem*sc.fps/int64(time.Second)
143+}
144+
145+// catchUp limits the lag of now behind slot next to maxRepeatSlots by moving
146+// the schedule start forward. It returns the resulting current slot (>= next).
147+func (sc *schedule) catchUp(next int64) int64 {
148+ cur := max(sc.slot(), next)
149+ if cur-next > maxRepeatSlots {
150+ sc.start = sc.start.Add(time.Since(sc.start) - sc.due(next+maxRepeatSlots))
151+ cur = max(sc.slot(), next)
152+ }
153+ return cur
154+}
155+
156+// writeTimed writes frame and returns how long the encoder took.
157+func (s *Service) writeTimed(ctx context.Context, frame ports.Frame) (time.Duration, error) {
158+ began := time.Now()
159+ err := s.video.Write(ctx, frame)
160+ return time.Since(began), err
161+}
162+
163+// recordLoop fills slots 1.. after the already written first frame. A slot
164+// missed because Capture ran long reuses the current frame, which is still
165+// valid until the next Capture, so the encoded duration stays exact even when
166+// wall time overruns. Encoder time does not cause repeats: the full duration
167+// of a repeated Write, and the part of a fresh Write beyond one frame
168+// interval, is added to the schedule start, so only Capture delay is
169+// duplicated. catchUp is a backstop that keeps at most maxRepeatSlots repeats
170+// per fresh Capture. A timed recording ends after ceil(duration*fps)
171+// frames, so a duration shorter than one frame interval encodes one frame and
172+// returns without waiting. Cancellation ends the recording at the last frame
173+// already written: nothing is written afterwards, because the writer is
174+// cancelled with ctx and storage may be invalidated by an interrupted Capture.
175+// Exact frame counts therefore hold only for timed recordings that are not
176+// cancelled. Slot n < total is due before the duration, so due cannot overflow.
177+func (s *Service) recordLoop(ctx context.Context, frame ports.Frame, sel ports.Selection) error {
178+ sc := newSchedule(time.Now(), sel)
179+ timer := time.NewTimer(time.Hour)
180+ defer timer.Stop()
181+ timer.Stop()
182+
183+ next := int64(1)
184+ for sc.total == 0 || next < sc.total {
185+ if wait := sc.due(next) - time.Since(sc.start); wait > 0 {
186+ timer.Reset(wait)
187+ select {
188+ case <-ctx.Done():
189+ return nil
190+ case <-timer.C:
191+ }
192+ }
193+ if ctx.Err() != nil {
194+ return nil
195+ }
196+
197+ cur := sc.catchUp(next)
198+ past := sc.total > 0 && cur >= sc.total
199+ if past {
200+ cur = sc.total - 1
201+ }
202+ // Slots already missed repeat the frame before it is invalidated.
203+ last := cur - 1
204+ if past {
205+ last = cur // window over: no capture for the final slot
206+ }
207+ for ; next <= last; next++ {
208+ if ctx.Err() != nil {
209+ return nil
210+ }
211+ took, err := s.writeTimed(ctx, frame)
212+ if err != nil {
213+ return writeResult(ctx, err)
214+ }
215+ sc.start = sc.start.Add(took) // repeated encoder time is not lag
216+ }
217+ if past {
218+ return nil
219+ }
220+
221+ var err error
222+ if frame, err = s.source.Capture(ctx, sel.Target); err != nil {
223+ if stopped(ctx, err) {
224+ return nil
225+ }
226+ return fmt.Errorf("capture frame: %w", err)
227+ }
228+ if err := frame.Validate(); err != nil {
229+ return fmt.Errorf("captured frame: %w", err)
230+ }
231+ if ctx.Err() != nil {
232+ return nil
233+ }
234+ took, err := s.writeTimed(ctx, frame)
235+ if err != nil {
236+ return writeResult(ctx, err)
237+ }
238+ // Only the part of a fresh Write beyond its own slot is delay.
239+ sc.start = sc.start.Add(max(0, took-sc.due(1)))
240+ next++
241+ }
242+ return nil
243+}
244+
245+func writeResult(ctx context.Context, err error) error {
246+ if stopped(ctx, err) {
247+ return nil
248+ }
249+ return fmt.Errorf("write frame: %w", err)
250+}
251+
252+// stopped reports whether the run is ending and err consists only of context
253+// cancellation or deadline leaves. A failure joined with cancellation is real.
254+func stopped(ctx context.Context, err error) bool {
255+ return ctx.Err() != nil && onlyContext(err)
256+}
257+
258+func onlyContext(err error) bool {
259+ switch e := err.(type) {
260+ case nil:
261+ return false
262+ case interface{ Unwrap() []error }:
263+ children := e.Unwrap()
264+ for _, c := range children {
265+ if !onlyContext(c) {
266+ return false
267+ }
268+ }
269+ return len(children) > 0
270+ case interface{ Unwrap() error }:
271+ return err == context.Canceled || err == context.DeadlineExceeded || onlyContext(e.Unwrap())
272+ default:
273+ return err == context.Canceled || err == context.DeadlineExceeded
274+ }
275+}
276+
277+func validateSelection(sel ports.Selection) error {
278+ switch sel.Mode {
279+ case ports.Screenshot, ports.Record:
280+ default:
281+ return fmt.Errorf("%w: unknown mode %q", ErrInvalidSelection, sel.Mode)
282+ }
283+ if sel.Target.OutputID == 0 {
284+ return fmt.Errorf("%w: output id is zero", ErrInvalidSelection)
285+ }
286+ if err := validateRegion(sel.Target.Region); err != nil {
287+ return err
288+ }
289+ if sel.Path == "" {
290+ return fmt.Errorf("%w: empty path", ErrInvalidSelection)
291+ }
292+ if sel.Duration < 0 {
293+ return fmt.Errorf("%w: negative duration %s", ErrInvalidSelection, sel.Duration)
294+ }
295+ if sel.Mode == ports.Record {
296+ return validateVideo(sel.Video)
297+ }
298+ return nil
299+}
300+
301+// validateRegion accepts the zero region (full output) or a region with
302+// positive size and nonnegative origin that fits in int32.
303+func validateRegion(r ports.Region) error {
304+ if r == (ports.Region{}) {
305+ return nil
306+ }
307+ x, y, w, h := int64(r.X), int64(r.Y), int64(r.Width), int64(r.Height)
308+ if x < 0 || y < 0 || w < 1 || h < 1 ||
309+ x > math.MaxInt32 || y > math.MaxInt32 || w > math.MaxInt32 || h > math.MaxInt32 ||
310+ x+w > math.MaxInt32 || y+h > math.MaxInt32 {
311+ return fmt.Errorf("%w: %w: %+v", ErrInvalidSelection, ports.ErrInvalidRegion, r)
312+ }
313+ return nil
314+}
315+
316+func validateVideo(v ports.VideoSettings) error {
317+ if v.FPS < 1 || v.FPS > ports.MaxFPS {
318+ return fmt.Errorf("%w: fps %d outside 1..%d", ErrInvalidSelection, v.FPS, ports.MaxFPS)
319+ }
320+ if v.Width == 0 && v.Height == 0 {
321+ return nil
322+ }
323+ w, h := v.Width, v.Height
324+ if w < 1 || h < 1 || w > ports.MaxDimension || h > ports.MaxDimension || w%2 != 0 || h%2 != 0 ||
325+ w*ports.BytesPerPixel > ports.MaxFrameBytes/h {
326+ return fmt.Errorf("%w: invalid output size %dx%d", ErrInvalidSelection, w, h)
327+ }
328+ return nil
329+}
A internal/core/service_test.go +648 -0
@@ -0,0 +1,648 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/nefercap/internal/core"
16+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
17+ "github.com/bnema/nefercap/internal/ports"
18+)
19+
20+var (
21+ frame = ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: XRGB}
22+ target = ports.Target{OutputID: 7, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 4}}
23+)
24+
25+const XRGB = ports.XRGB8888
26+
27+type harness struct {
28+ src *portsmocks.MockSource
29+ png *portsmocks.MockScreenshotWriter
30+ vid *portsmocks.MockVideoWriter
31+ svc *core.Service
32+}
33+
34+func newHarness(t *testing.T) harness {
35+ t.Helper()
36+ h := harness{
37+ src: portsmocks.NewMockSource(t),
38+ png: portsmocks.NewMockScreenshotWriter(t),
39+ vid: portsmocks.NewMockVideoWriter(t),
40+ }
41+ h.svc = core.New(h.src, h.png, h.vid)
42+ return h
43+}
44+
45+func shot() ports.Selection {
46+ return ports.Selection{Mode: ports.Screenshot, Target: target, Path: "a.png"}
47+}
48+
49+func rec(d time.Duration) ports.Selection {
50+ return ports.Selection{
51+ Mode: ports.Record, Target: target, Path: "a.mkv", Duration: d,
52+ Video: ports.VideoSettings{FPS: 120},
53+ }
54+}
55+
56+func TestInvalidSelectionsMakeNoCalls(t *testing.T) {
57+ const maxI32 = 1<<31 - 1
58+ cases := map[string]func(*ports.Selection){
59+ "empty mode": func(s *ports.Selection) { s.Mode = "" },
60+ "unknown mode": func(s *ports.Selection) { s.Mode = "gif" },
61+ "zero output": func(s *ports.Selection) { s.Target.OutputID = 0 },
62+ "negative x": func(s *ports.Selection) { s.Target.Region = ports.Region{X: -1, Width: 1, Height: 1} },
63+ "negative y": func(s *ports.Selection) { s.Target.Region = ports.Region{Y: -1, Width: 1, Height: 1} },
64+ "zero width": func(s *ports.Selection) { s.Target.Region = ports.Region{X: 1, Height: 1} },
65+ "zero height": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: 1} },
66+ "negative width": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: -1, Height: 1} },
67+ "x overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{X: maxI32, Width: 1, Height: 1} },
68+ "width overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: maxI32 + 1, Height: 1} },
69+ "empty path": func(s *ports.Selection) { s.Path = "" },
70+ "negative duration": func(s *ports.Selection) { s.Duration = -time.Second },
71+ }
72+ for name, mutate := range cases {
73+ for _, base := range []ports.Selection{shot(), rec(time.Second)} {
74+ t.Run(string(base.Mode)+"/"+name, func(t *testing.T) {
75+ h := newHarness(t)
76+ sel := base
77+ mutate(&sel)
78+ err := h.svc.Run(context.Background(), sel)
79+ require.ErrorIs(t, err, core.ErrInvalidSelection)
80+ })
81+ }
82+ }
83+
84+ videos := map[string]ports.VideoSettings{
85+ "fps zero": {},
86+ "fps negative": {FPS: -1},
87+ "fps too high": {FPS: 121},
88+ "width only": {FPS: 30, Width: 640},
89+ "height only": {FPS: 30, Height: 480},
90+ "odd width": {FPS: 30, Width: 641, Height: 480},
91+ "odd height": {FPS: 30, Width: 640, Height: 481},
92+ "negative size": {FPS: 30, Width: -2, Height: -2},
93+ "oversized": {FPS: 30, Width: 16386, Height: 2},
94+ "frame too big": {FPS: 30, Width: 16384, Height: 16384},
95+ "oversized high": {FPS: 30, Width: 2, Height: 16386},
96+ }
97+ for name, v := range videos {
98+ t.Run("record/"+name, func(t *testing.T) {
99+ h := newHarness(t)
100+ sel := rec(0)
101+ sel.Video = v
102+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
103+ })
104+ }
105+}
106+
107+func TestRegionErrorIsInvalidRegion(t *testing.T) {
108+ h := newHarness(t)
109+ sel := shot()
110+ sel.Target.Region = ports.Region{X: -1, Width: 1, Height: 1}
111+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrInvalidRegion)
112+}
113+
114+func TestValidSelectionsAreAccepted(t *testing.T) {
115+ sizes := []ports.VideoSettings{{FPS: 1}, {FPS: 120, Width: 2, Height: 2}, {FPS: 30, Width: 16384, Height: 4096}}
116+ for _, v := range sizes {
117+ h := newHarness(t)
118+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
119+ h.vid.EXPECT().Start(mock.Anything, frame, "a.mkv", v).Return(errors.New("stop")).Once()
120+ h.vid.EXPECT().Abort().Return(nil).Once()
121+ sel := rec(0)
122+ sel.Video = v
123+ require.ErrorContains(t, h.svc.Run(context.Background(), sel), "stop")
124+ }
125+ // Zero region means full output.
126+ h := newHarness(t)
127+ full := ports.Target{OutputID: 1}
128+ h.src.EXPECT().Capture(mock.Anything, full).Return(frame, nil).Once()
129+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(nil).Once()
130+ sel := shot()
131+ sel.Target = full
132+ require.NoError(t, h.svc.Run(context.Background(), sel))
133+}
134+
135+func TestSelectionAllocations(t *testing.T) {
136+ sels := []ports.Selection{shot(), rec(time.Second)}
137+ h := newHarness(t)
138+ ctx, cancel := context.WithCancel(context.Background())
139+ cancel() // validation passes, then Run stops before any port call
140+ for _, sel := range sels {
141+ allocs := testing.AllocsPerRun(100, func() {
142+ if err := h.svc.Run(ctx, sel); !errors.Is(err, context.Canceled) {
143+ t.Fatalf("unexpected result %v", err)
144+ }
145+ })
146+ assert.Zero(t, allocs, sel.Mode)
147+ }
148+}
149+
150+func TestScreenshot(t *testing.T) {
151+ h := newHarness(t)
152+ ctx := context.Background()
153+ c := h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
154+ h.png.EXPECT().Save(ctx, frame, "a.png").Return(nil).Once().NotBefore(c)
155+ require.NoError(t, h.svc.Run(ctx, shot()))
156+}
157+
158+func TestScreenshotFailures(t *testing.T) {
159+ boom := errors.New("boom")
160+ t.Run("capture", func(t *testing.T) {
161+ h := newHarness(t)
162+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
163+ require.ErrorIs(t, h.svc.Run(context.Background(), shot()), boom)
164+ })
165+ t.Run("invalid frame", func(t *testing.T) {
166+ h := newHarness(t)
167+ bad := frame
168+ bad.Stride = 4
169+ h.src.EXPECT().Capture(mock.Anything, target).Return(bad, nil).Once()
170+ require.Error(t, h.svc.Run(context.Background(), shot()))
171+ })
172+ t.Run("save", func(t *testing.T) {
173+ h := newHarness(t)
174+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
175+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(errors.Join(boom, ports.ErrPathExists)).Once()
176+ err := h.svc.Run(context.Background(), shot())
177+ require.ErrorIs(t, err, boom)
178+ require.ErrorIs(t, err, ports.ErrPathExists)
179+ })
180+ t.Run("already cancelled", func(t *testing.T) {
181+ h := newHarness(t)
182+ ctx, cancel := context.WithCancel(context.Background())
183+ cancel()
184+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
185+ })
186+ t.Run("cancelled during capture", func(t *testing.T) {
187+ h := newHarness(t)
188+ ctx, cancel := context.WithCancel(context.Background())
189+ h.src.EXPECT().Capture(ctx, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
190+ cancel()
191+ return ports.Frame{}, ctx.Err()
192+ }).Once()
193+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
194+ })
195+ t.Run("cancelled during save", func(t *testing.T) {
196+ h := newHarness(t)
197+ ctx, cancel := context.WithCancel(context.Background())
198+ h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
199+ h.png.EXPECT().Save(ctx, frame, "a.png").RunAndReturn(func(ctx context.Context, _ ports.Frame, _ string) error {
200+ cancel()
201+ return ctx.Err()
202+ }).Once()
203+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
204+ })
205+}
206+
207+// recordOrder sets the mandatory prefix: Capture, Start, first Write.
208+func recordOrder(h harness, sel ports.Selection) (first *mock.Call) {
209+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
210+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(nil).Once().NotBefore(c)
211+ return s
212+}
213+
214+func TestRecordFirstWriteCompletesBeforeTick(t *testing.T) {
215+ // 1 fps: only the first frame is written in a short run.
216+ h := newHarness(t)
217+ sel := rec(30 * time.Millisecond)
218+ sel.Video.FPS = 1
219+ start := recordOrder(h, sel)
220+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
221+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
222+ require.NoError(t, h.svc.Run(context.Background(), sel))
223+}
224+
225+func TestRecordCancelFinalizesGracefully(t *testing.T) {
226+ h := newHarness(t)
227+ sel := rec(0)
228+ ctx, cancel := context.WithCancel(context.Background())
229+ start := recordOrder(h, sel)
230+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
231+ var n atomic.Int32
232+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
233+ if n.Add(1) == 3 {
234+ cancel()
235+ }
236+ return nil
237+ }).NotBefore(start)
238+ w.Maybe()
239+ h.vid.EXPECT().Close().Return(nil).Once()
240+ require.NoError(t, h.svc.Run(ctx, sel))
241+ assert.GreaterOrEqual(t, n.Load(), int32(3))
242+}
243+
244+func TestRecordCancelInterruptsBlockedCapture(t *testing.T) {
245+ h := newHarness(t)
246+ sel := rec(0)
247+ ctx, cancel := context.WithCancel(context.Background())
248+ start := recordOrder(h, sel)
249+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
250+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
251+ cancel()
252+ <-ctx.Done()
253+ return ports.Frame{}, ctx.Err()
254+ }).Once()
255+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
256+ require.NoError(t, h.svc.Run(ctx, sel))
257+}
258+
259+func TestRecordCancelDuringFirstWriteAborts(t *testing.T) {
260+ h := newHarness(t)
261+ sel := rec(0)
262+ ctx, cancel := context.WithCancel(context.Background())
263+ start := recordOrder(h, sel)
264+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
265+ cancel()
266+ return ctx.Err()
267+ }).Once().NotBefore(start)
268+ h.vid.EXPECT().Abort().Return(nil).Once().NotBefore(w)
269+ err := h.svc.Run(ctx, sel)
270+ require.ErrorIs(t, err, context.Canceled)
271+}
272+
273+func TestRecordCancelDuringLaterWrite(t *testing.T) {
274+ h := newHarness(t)
275+ sel := rec(0)
276+ ctx, cancel := context.WithCancel(context.Background())
277+ start := recordOrder(h, sel)
278+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
279+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
280+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
281+ cancel()
282+ return ctx.Err()
283+ }).Once().NotBefore(first)
284+ h.vid.EXPECT().Close().Return(nil).Once()
285+ require.NoError(t, h.svc.Run(ctx, sel))
286+}
287+
288+func TestRecordJoinedFailureIsNotSuppressedByCancellation(t *testing.T) {
289+ boom := errors.New("boom")
290+ joins := map[string]error{
291+ "join": errors.Join(context.Canceled, boom),
292+ "wrapped": fmt.Errorf("x: %w", errors.Join(boom, fmt.Errorf("y: %w", context.Canceled))),
293+ "multi": fmt.Errorf("%w; %w", context.Canceled, boom),
294+ }
295+ for name, werr := range joins {
296+ t.Run("write/"+name, func(t *testing.T) {
297+ h := newHarness(t)
298+ sel := rec(0)
299+ ctx, cancel := context.WithCancel(context.Background())
300+ start := recordOrder(h, sel)
301+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
302+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
303+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
304+ cancel()
305+ return werr
306+ }).Once().NotBefore(first)
307+ h.vid.EXPECT().Close().Return(nil).Once()
308+ require.ErrorIs(t, h.svc.Run(ctx, sel), boom)
309+ })
310+ t.Run("capture/"+name, func(t *testing.T) {
311+ h := newHarness(t)
312+ sel := rec(0)
313+ ctx, cancel := context.WithCancel(context.Background())
314+ start := recordOrder(h, sel)
315+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
316+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
317+ cancel()
318+ return ports.Frame{}, werr
319+ }).Once()
320+ h.vid.EXPECT().Close().Return(nil).Once()
321+ require.ErrorIs(t, h.svc.Run(ctx, sel), boom)
322+ })
323+ }
324+ t.Run("wrapped cancellation alone is a clean stop", func(t *testing.T) {
325+ h := newHarness(t)
326+ sel := rec(0)
327+ ctx, cancel := context.WithCancel(context.Background())
328+ start := recordOrder(h, sel)
329+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
330+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
331+ cancel()
332+ return ports.Frame{}, fmt.Errorf("wayland: %w", errors.Join(context.Canceled, context.Canceled))
333+ }).Once()
334+ h.vid.EXPECT().Close().Return(nil).Once()
335+ require.NoError(t, h.svc.Run(ctx, sel))
336+ })
337+}
338+
339+// slotRecorder tracks the most recently captured frame: only it may be written.
340+type slotRecorder struct {
341+ current *byte
342+ captures atomic.Int32
343+ writes atomic.Int32
344+}
345+
346+func newFrame() ports.Frame {
347+ f := frame
348+ f.Pixels = make([]byte, len(frame.Pixels))
349+ return f
350+}
351+
352+func TestRecordExactFrameCountWithSlowStartAndCapture(t *testing.T) {
353+ cases := []struct {
354+ name string
355+ duration time.Duration
356+ fps int
357+ want int32
358+ }{
359+ {"slow capture 60fps", 300 * time.Millisecond, 60, 18},
360+ {"fractional slot rounds up", 25 * time.Millisecond, 60, 2},
361+ {"single slot", 10 * time.Millisecond, 30, 1},
362+ {"fast", 100 * time.Millisecond, 30, 3},
363+ }
364+ for _, tc := range cases {
365+ t.Run(tc.name, func(t *testing.T) {
366+ h := newHarness(t)
367+ sel := rec(tc.duration)
368+ sel.Video.FPS = tc.fps
369+ var r slotRecorder
370+ first := newFrame()
371+ r.current = &first.Pixels[0]
372+ var closed atomic.Bool
373+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
374+ r.captures.Add(1)
375+ f := first
376+ if r.captures.Load() > 1 {
377+ time.Sleep(25 * time.Millisecond)
378+ f = newFrame()
379+ }
380+ r.current = &f.Pixels[0]
381+ return f, nil
382+ }).Maybe()
383+ s := h.vid.EXPECT().Start(mock.Anything, mock.Anything, sel.Path, sel.Video).RunAndReturn(func(context.Context, ports.Frame, string, ports.VideoSettings) error {
384+ time.Sleep(100 * time.Millisecond) // must not count against the duration
385+ return nil
386+ }).Once()
387+ h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.Frame) error {
388+ require.False(t, closed.Load(), "write after close")
389+ require.Same(t, r.current, &f.Pixels[0], "stale or unknown frame written")
390+ r.writes.Add(1)
391+ return nil
392+ }).NotBefore(s).Maybe()
393+ h.vid.EXPECT().Close().RunAndReturn(func() error {
394+ closed.Store(true)
395+ return nil
396+ }).Once()
397+
398+ began := time.Now()
399+ require.NoError(t, h.svc.Run(context.Background(), sel))
400+ assert.Equal(t, tc.want, r.writes.Load())
401+ assert.LessOrEqual(t, r.captures.Load(), tc.want)
402+ assert.GreaterOrEqual(t, time.Since(began), 100*time.Millisecond+tc.duration-time.Second/time.Duration(tc.fps))
403+ })
404+ }
405+}
406+
407+// backpressure wires a slow Write and distinct capture storage, and reports
408+// fresh captures, total writes and the longest run of repeated frames.
409+type backpressure struct {
410+ captures atomic.Int32
411+ writes atomic.Int32
412+ maxRun int
413+ run int
414+ last *byte
415+}
416+
417+func (bp *backpressure) wire(h harness, start *mock.Call, slow time.Duration) {
418+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
419+ bp.captures.Add(1)
420+ return newFrame(), nil
421+ }).Maybe()
422+ h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.Frame) error {
423+ time.Sleep(slow)
424+ bp.writes.Add(1)
425+ if &f.Pixels[0] == bp.last {
426+ bp.run++
427+ } else {
428+ bp.run = 0
429+ }
430+ bp.maxRun = max(bp.maxRun, bp.run)
431+ bp.last = &f.Pixels[0]
432+ return nil
433+ }).NotBefore(start).Maybe()
434+}
435+
436+func TestRecordEncoderBackpressureKeepsCapturesFresh(t *testing.T) {
437+ h := newHarness(t)
438+ sel := rec(2 * time.Second)
439+ sel.Video.FPS = 60
440+ start := recordOrder(h, sel)
441+ var bp backpressure
442+ bp.wire(h, start, 20*time.Millisecond)
443+ h.vid.EXPECT().Close().Return(nil).Once()
444+
445+ require.NoError(t, h.svc.Run(context.Background(), sel))
446+ assert.Equal(t, int32(120), bp.writes.Load(), "exact encoded length")
447+ assert.GreaterOrEqual(t, bp.captures.Load(), int32(40), "captures must not starve")
448+ assert.LessOrEqual(t, bp.maxRun, 2, "repeat backlog is bounded")
449+}
450+
451+func TestRecordSlightWriteOverrunStaysFresh(t *testing.T) {
452+ h := newHarness(t)
453+ sel := rec(time.Second)
454+ sel.Video.FPS = 60
455+ start := recordOrder(h, sel)
456+ var bp backpressure
457+ bp.wire(h, start, 17*time.Millisecond) // just over the 16.7ms slot
458+ h.vid.EXPECT().Close().Return(nil).Once()
459+
460+ require.NoError(t, h.svc.Run(context.Background(), sel))
461+ assert.Equal(t, int32(60), bp.writes.Load(), "exact encoded length")
462+ assert.GreaterOrEqual(t, bp.captures.Load(), int32(54), "over 90 percent of frames are fresh")
463+}
464+
465+func TestRecordSlowCaptureStillRepeats(t *testing.T) {
466+ h := newHarness(t)
467+ sel := rec(time.Second)
468+ sel.Video.FPS = 60
469+ start := recordOrder(h, sel)
470+ var captures atomic.Int32
471+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
472+ captures.Add(1)
473+ time.Sleep(25 * time.Millisecond)
474+ return newFrame(), nil
475+ }).Maybe()
476+ var writes atomic.Int32
477+ h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.Frame) error {
478+ time.Sleep(time.Millisecond)
479+ writes.Add(1)
480+ return nil
481+ }).NotBefore(start).Maybe()
482+ h.vid.EXPECT().Close().Return(nil).Once()
483+
484+ require.NoError(t, h.svc.Run(context.Background(), sel))
485+ assert.Equal(t, int32(60), writes.Load(), "exact encoded length")
486+ assert.Less(t, captures.Load(), int32(45), "slow captures are covered by repeated frames")
487+ assert.Greater(t, captures.Load(), int32(10))
488+}
489+
490+func TestRecordUntimedBackpressureBoundsRepeats(t *testing.T) {
491+ h := newHarness(t)
492+ sel := rec(0)
493+ sel.Video.FPS = 60
494+ ctx, cancel := context.WithCancel(context.Background())
495+ start := recordOrder(h, sel)
496+ var bp backpressure
497+ bp.wire(h, start, 40*time.Millisecond) // slower than two slots per frame
498+ h.vid.EXPECT().Close().Return(nil).Once()
499+
500+ time.AfterFunc(time.Second, cancel)
501+ require.NoError(t, h.svc.Run(ctx, sel))
502+ assert.Greater(t, bp.captures.Load(), int32(5))
503+ assert.LessOrEqual(t, bp.maxRun, 3, "repeated writes between fresh captures")
504+}
505+
506+func TestRecordCancelWritesNothingAfterStop(t *testing.T) {
507+ h := newHarness(t)
508+ sel := rec(0)
509+ sel.Video.FPS = 50
510+ ctx, cancel := context.WithCancel(context.Background())
511+ start := recordOrder(h, sel)
512+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
513+ var writes atomic.Int32
514+ var closed atomic.Bool
515+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
516+ require.NoError(t, ctx.Err(), "write after stop")
517+ if writes.Add(1) == 4 {
518+ cancel() // stop arrives while this frame is being written
519+ }
520+ return nil
521+ }).NotBefore(start).Maybe()
522+ h.vid.EXPECT().Close().RunAndReturn(func() error {
523+ closed.Store(true)
524+ return nil
525+ }).Once()
526+
527+ require.NoError(t, h.svc.Run(ctx, sel))
528+ assert.Equal(t, int32(4), writes.Load(), "recording ends at the last frame written before the stop")
529+ assert.True(t, closed.Load())
530+}
531+
532+func TestRecordHugeDurationDoesNotOverflow(t *testing.T) {
533+ h := newHarness(t)
534+ sel := rec(time.Duration(1<<63 - 1))
535+ ctx, cancel := context.WithCancel(context.Background())
536+ start := recordOrder(h, sel)
537+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
538+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
539+ h.vid.EXPECT().Close().Return(nil).Once()
540+ time.AfterFunc(40*time.Millisecond, cancel)
541+ require.NoError(t, h.svc.Run(ctx, sel))
542+}
543+
544+func TestRecordAlreadyCancelled(t *testing.T) {
545+ h := newHarness(t)
546+ ctx, cancel := context.WithCancel(context.Background())
547+ cancel()
548+ require.ErrorIs(t, h.svc.Run(ctx, rec(0)), context.Canceled)
549+}
550+
551+func TestRecordStartFailures(t *testing.T) {
552+ boom := errors.New("boom")
553+ abortErr := errors.New("abort failed")
554+ t.Run("first capture", func(t *testing.T) {
555+ h := newHarness(t)
556+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
557+ require.ErrorIs(t, h.svc.Run(context.Background(), rec(0)), boom)
558+ })
559+ t.Run("invalid first frame", func(t *testing.T) {
560+ h := newHarness(t)
561+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{Width: 2}, nil).Once()
562+ require.Error(t, h.svc.Run(context.Background(), rec(0)))
563+ })
564+ t.Run("start with abort failure", func(t *testing.T) {
565+ h := newHarness(t)
566+ sel := rec(0)
567+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
568+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(boom).Once().NotBefore(c)
569+ h.vid.EXPECT().Abort().Return(abortErr).Once().NotBefore(s)
570+ err := h.svc.Run(context.Background(), sel)
571+ require.ErrorIs(t, err, boom)
572+ require.ErrorIs(t, err, abortErr)
573+ })
574+ t.Run("path exists", func(t *testing.T) {
575+ h := newHarness(t)
576+ sel := rec(0)
577+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
578+ h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(ports.ErrPathExists).Once()
579+ h.vid.EXPECT().Abort().Return(nil).Once()
580+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrPathExists)
581+ })
582+}
583+
584+func TestRecordRuntimeFailures(t *testing.T) {
585+ boom := errors.New("boom")
586+ closeErr := errors.New("close failed")
587+
588+ t.Run("first write", func(t *testing.T) {
589+ h := newHarness(t)
590+ sel := rec(0)
591+ start := recordOrder(h, sel)
592+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(boom).Once().NotBefore(start)
593+ h.vid.EXPECT().Abort().Return(closeErr).Once().NotBefore(w)
594+ err := h.svc.Run(context.Background(), sel)
595+ require.ErrorIs(t, err, boom)
596+ require.ErrorIs(t, err, closeErr)
597+ })
598+ t.Run("later capture", func(t *testing.T) {
599+ h := newHarness(t)
600+ sel := rec(0)
601+ start := recordOrder(h, sel)
602+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
603+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
604+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
605+ err := h.svc.Run(context.Background(), sel)
606+ require.ErrorIs(t, err, boom)
607+ assert.NotErrorIs(t, err, closeErr)
608+ })
609+ t.Run("later invalid frame", func(t *testing.T) {
610+ h := newHarness(t)
611+ sel := rec(0)
612+ start := recordOrder(h, sel)
613+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
614+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, nil).Once()
615+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
616+ require.Error(t, h.svc.Run(context.Background(), sel))
617+ })
618+ t.Run("later capture and close", func(t *testing.T) {
619+ h := newHarness(t)
620+ sel := rec(0)
621+ start := recordOrder(h, sel)
622+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
623+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
624+ h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
625+ err := h.svc.Run(context.Background(), sel)
626+ require.ErrorIs(t, err, boom)
627+ require.ErrorIs(t, err, closeErr)
628+ })
629+ t.Run("geometry change", func(t *testing.T) {
630+ h := newHarness(t)
631+ sel := rec(0)
632+ start := recordOrder(h, sel)
633+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
634+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
635+ h.vid.EXPECT().Write(mock.Anything, frame).Return(ports.ErrGeometryChanged).Once().NotBefore(first)
636+ h.vid.EXPECT().Close().Return(nil).Once()
637+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrGeometryChanged)
638+ })
639+ t.Run("close only", func(t *testing.T) {
640+ h := newHarness(t)
641+ sel := rec(20 * time.Millisecond)
642+ start := recordOrder(h, sel)
643+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
644+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
645+ h.vid.EXPECT().Close().Return(closeErr).Once()
646+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), closeErr)
647+ })
648+}
@@ -0,0 +1,648 @@
1+package core_test
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "sync/atomic"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/nefercap/internal/core"
16+ portsmocks "github.com/bnema/nefercap/internal/mocks/ports"
17+ "github.com/bnema/nefercap/internal/ports"
18+)
19+
20+var (
21+ frame = ports.Frame{Pixels: make([]byte, 16), Width: 2, Height: 2, Stride: 8, Format: XRGB}
22+ target = ports.Target{OutputID: 7, Region: ports.Region{X: 1, Y: 2, Width: 3, Height: 4}}
23+)
24+
25+const XRGB = ports.XRGB8888
26+
27+type harness struct {
28+ src *portsmocks.MockSource
29+ png *portsmocks.MockScreenshotWriter
30+ vid *portsmocks.MockVideoWriter
31+ svc *core.Service
32+}
33+
34+func newHarness(t *testing.T) harness {
35+ t.Helper()
36+ h := harness{
37+ src: portsmocks.NewMockSource(t),
38+ png: portsmocks.NewMockScreenshotWriter(t),
39+ vid: portsmocks.NewMockVideoWriter(t),
40+ }
41+ h.svc = core.New(h.src, h.png, h.vid)
42+ return h
43+}
44+
45+func shot() ports.Selection {
46+ return ports.Selection{Mode: ports.Screenshot, Target: target, Path: "a.png"}
47+}
48+
49+func rec(d time.Duration) ports.Selection {
50+ return ports.Selection{
51+ Mode: ports.Record, Target: target, Path: "a.mkv", Duration: d,
52+ Video: ports.VideoSettings{FPS: 120},
53+ }
54+}
55+
56+func TestInvalidSelectionsMakeNoCalls(t *testing.T) {
57+ const maxI32 = 1<<31 - 1
58+ cases := map[string]func(*ports.Selection){
59+ "empty mode": func(s *ports.Selection) { s.Mode = "" },
60+ "unknown mode": func(s *ports.Selection) { s.Mode = "gif" },
61+ "zero output": func(s *ports.Selection) { s.Target.OutputID = 0 },
62+ "negative x": func(s *ports.Selection) { s.Target.Region = ports.Region{X: -1, Width: 1, Height: 1} },
63+ "negative y": func(s *ports.Selection) { s.Target.Region = ports.Region{Y: -1, Width: 1, Height: 1} },
64+ "zero width": func(s *ports.Selection) { s.Target.Region = ports.Region{X: 1, Height: 1} },
65+ "zero height": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: 1} },
66+ "negative width": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: -1, Height: 1} },
67+ "x overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{X: maxI32, Width: 1, Height: 1} },
68+ "width overflow": func(s *ports.Selection) { s.Target.Region = ports.Region{Width: maxI32 + 1, Height: 1} },
69+ "empty path": func(s *ports.Selection) { s.Path = "" },
70+ "negative duration": func(s *ports.Selection) { s.Duration = -time.Second },
71+ }
72+ for name, mutate := range cases {
73+ for _, base := range []ports.Selection{shot(), rec(time.Second)} {
74+ t.Run(string(base.Mode)+"/"+name, func(t *testing.T) {
75+ h := newHarness(t)
76+ sel := base
77+ mutate(&sel)
78+ err := h.svc.Run(context.Background(), sel)
79+ require.ErrorIs(t, err, core.ErrInvalidSelection)
80+ })
81+ }
82+ }
83+
84+ videos := map[string]ports.VideoSettings{
85+ "fps zero": {},
86+ "fps negative": {FPS: -1},
87+ "fps too high": {FPS: 121},
88+ "width only": {FPS: 30, Width: 640},
89+ "height only": {FPS: 30, Height: 480},
90+ "odd width": {FPS: 30, Width: 641, Height: 480},
91+ "odd height": {FPS: 30, Width: 640, Height: 481},
92+ "negative size": {FPS: 30, Width: -2, Height: -2},
93+ "oversized": {FPS: 30, Width: 16386, Height: 2},
94+ "frame too big": {FPS: 30, Width: 16384, Height: 16384},
95+ "oversized high": {FPS: 30, Width: 2, Height: 16386},
96+ }
97+ for name, v := range videos {
98+ t.Run("record/"+name, func(t *testing.T) {
99+ h := newHarness(t)
100+ sel := rec(0)
101+ sel.Video = v
102+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), core.ErrInvalidSelection)
103+ })
104+ }
105+}
106+
107+func TestRegionErrorIsInvalidRegion(t *testing.T) {
108+ h := newHarness(t)
109+ sel := shot()
110+ sel.Target.Region = ports.Region{X: -1, Width: 1, Height: 1}
111+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrInvalidRegion)
112+}
113+
114+func TestValidSelectionsAreAccepted(t *testing.T) {
115+ sizes := []ports.VideoSettings{{FPS: 1}, {FPS: 120, Width: 2, Height: 2}, {FPS: 30, Width: 16384, Height: 4096}}
116+ for _, v := range sizes {
117+ h := newHarness(t)
118+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
119+ h.vid.EXPECT().Start(mock.Anything, frame, "a.mkv", v).Return(errors.New("stop")).Once()
120+ h.vid.EXPECT().Abort().Return(nil).Once()
121+ sel := rec(0)
122+ sel.Video = v
123+ require.ErrorContains(t, h.svc.Run(context.Background(), sel), "stop")
124+ }
125+ // Zero region means full output.
126+ h := newHarness(t)
127+ full := ports.Target{OutputID: 1}
128+ h.src.EXPECT().Capture(mock.Anything, full).Return(frame, nil).Once()
129+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(nil).Once()
130+ sel := shot()
131+ sel.Target = full
132+ require.NoError(t, h.svc.Run(context.Background(), sel))
133+}
134+
135+func TestSelectionAllocations(t *testing.T) {
136+ sels := []ports.Selection{shot(), rec(time.Second)}
137+ h := newHarness(t)
138+ ctx, cancel := context.WithCancel(context.Background())
139+ cancel() // validation passes, then Run stops before any port call
140+ for _, sel := range sels {
141+ allocs := testing.AllocsPerRun(100, func() {
142+ if err := h.svc.Run(ctx, sel); !errors.Is(err, context.Canceled) {
143+ t.Fatalf("unexpected result %v", err)
144+ }
145+ })
146+ assert.Zero(t, allocs, sel.Mode)
147+ }
148+}
149+
150+func TestScreenshot(t *testing.T) {
151+ h := newHarness(t)
152+ ctx := context.Background()
153+ c := h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
154+ h.png.EXPECT().Save(ctx, frame, "a.png").Return(nil).Once().NotBefore(c)
155+ require.NoError(t, h.svc.Run(ctx, shot()))
156+}
157+
158+func TestScreenshotFailures(t *testing.T) {
159+ boom := errors.New("boom")
160+ t.Run("capture", func(t *testing.T) {
161+ h := newHarness(t)
162+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
163+ require.ErrorIs(t, h.svc.Run(context.Background(), shot()), boom)
164+ })
165+ t.Run("invalid frame", func(t *testing.T) {
166+ h := newHarness(t)
167+ bad := frame
168+ bad.Stride = 4
169+ h.src.EXPECT().Capture(mock.Anything, target).Return(bad, nil).Once()
170+ require.Error(t, h.svc.Run(context.Background(), shot()))
171+ })
172+ t.Run("save", func(t *testing.T) {
173+ h := newHarness(t)
174+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
175+ h.png.EXPECT().Save(mock.Anything, frame, "a.png").Return(errors.Join(boom, ports.ErrPathExists)).Once()
176+ err := h.svc.Run(context.Background(), shot())
177+ require.ErrorIs(t, err, boom)
178+ require.ErrorIs(t, err, ports.ErrPathExists)
179+ })
180+ t.Run("already cancelled", func(t *testing.T) {
181+ h := newHarness(t)
182+ ctx, cancel := context.WithCancel(context.Background())
183+ cancel()
184+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
185+ })
186+ t.Run("cancelled during capture", func(t *testing.T) {
187+ h := newHarness(t)
188+ ctx, cancel := context.WithCancel(context.Background())
189+ h.src.EXPECT().Capture(ctx, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
190+ cancel()
191+ return ports.Frame{}, ctx.Err()
192+ }).Once()
193+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
194+ })
195+ t.Run("cancelled during save", func(t *testing.T) {
196+ h := newHarness(t)
197+ ctx, cancel := context.WithCancel(context.Background())
198+ h.src.EXPECT().Capture(ctx, target).Return(frame, nil).Once()
199+ h.png.EXPECT().Save(ctx, frame, "a.png").RunAndReturn(func(ctx context.Context, _ ports.Frame, _ string) error {
200+ cancel()
201+ return ctx.Err()
202+ }).Once()
203+ require.ErrorIs(t, h.svc.Run(ctx, shot()), context.Canceled)
204+ })
205+}
206+
207+// recordOrder sets the mandatory prefix: Capture, Start, first Write.
208+func recordOrder(h harness, sel ports.Selection) (first *mock.Call) {
209+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
210+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(nil).Once().NotBefore(c)
211+ return s
212+}
213+
214+func TestRecordFirstWriteCompletesBeforeTick(t *testing.T) {
215+ // 1 fps: only the first frame is written in a short run.
216+ h := newHarness(t)
217+ sel := rec(30 * time.Millisecond)
218+ sel.Video.FPS = 1
219+ start := recordOrder(h, sel)
220+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
221+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
222+ require.NoError(t, h.svc.Run(context.Background(), sel))
223+}
224+
225+func TestRecordCancelFinalizesGracefully(t *testing.T) {
226+ h := newHarness(t)
227+ sel := rec(0)
228+ ctx, cancel := context.WithCancel(context.Background())
229+ start := recordOrder(h, sel)
230+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
231+ var n atomic.Int32
232+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
233+ if n.Add(1) == 3 {
234+ cancel()
235+ }
236+ return nil
237+ }).NotBefore(start)
238+ w.Maybe()
239+ h.vid.EXPECT().Close().Return(nil).Once()
240+ require.NoError(t, h.svc.Run(ctx, sel))
241+ assert.GreaterOrEqual(t, n.Load(), int32(3))
242+}
243+
244+func TestRecordCancelInterruptsBlockedCapture(t *testing.T) {
245+ h := newHarness(t)
246+ sel := rec(0)
247+ ctx, cancel := context.WithCancel(context.Background())
248+ start := recordOrder(h, sel)
249+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
250+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(ctx context.Context, _ ports.Target) (ports.Frame, error) {
251+ cancel()
252+ <-ctx.Done()
253+ return ports.Frame{}, ctx.Err()
254+ }).Once()
255+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
256+ require.NoError(t, h.svc.Run(ctx, sel))
257+}
258+
259+func TestRecordCancelDuringFirstWriteAborts(t *testing.T) {
260+ h := newHarness(t)
261+ sel := rec(0)
262+ ctx, cancel := context.WithCancel(context.Background())
263+ start := recordOrder(h, sel)
264+ w := h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
265+ cancel()
266+ return ctx.Err()
267+ }).Once().NotBefore(start)
268+ h.vid.EXPECT().Abort().Return(nil).Once().NotBefore(w)
269+ err := h.svc.Run(ctx, sel)
270+ require.ErrorIs(t, err, context.Canceled)
271+}
272+
273+func TestRecordCancelDuringLaterWrite(t *testing.T) {
274+ h := newHarness(t)
275+ sel := rec(0)
276+ ctx, cancel := context.WithCancel(context.Background())
277+ start := recordOrder(h, sel)
278+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
279+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
280+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
281+ cancel()
282+ return ctx.Err()
283+ }).Once().NotBefore(first)
284+ h.vid.EXPECT().Close().Return(nil).Once()
285+ require.NoError(t, h.svc.Run(ctx, sel))
286+}
287+
288+func TestRecordJoinedFailureIsNotSuppressedByCancellation(t *testing.T) {
289+ boom := errors.New("boom")
290+ joins := map[string]error{
291+ "join": errors.Join(context.Canceled, boom),
292+ "wrapped": fmt.Errorf("x: %w", errors.Join(boom, fmt.Errorf("y: %w", context.Canceled))),
293+ "multi": fmt.Errorf("%w; %w", context.Canceled, boom),
294+ }
295+ for name, werr := range joins {
296+ t.Run("write/"+name, func(t *testing.T) {
297+ h := newHarness(t)
298+ sel := rec(0)
299+ ctx, cancel := context.WithCancel(context.Background())
300+ start := recordOrder(h, sel)
301+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
302+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
303+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(context.Context, ports.Frame) error {
304+ cancel()
305+ return werr
306+ }).Once().NotBefore(first)
307+ h.vid.EXPECT().Close().Return(nil).Once()
308+ require.ErrorIs(t, h.svc.Run(ctx, sel), boom)
309+ })
310+ t.Run("capture/"+name, func(t *testing.T) {
311+ h := newHarness(t)
312+ sel := rec(0)
313+ ctx, cancel := context.WithCancel(context.Background())
314+ start := recordOrder(h, sel)
315+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
316+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
317+ cancel()
318+ return ports.Frame{}, werr
319+ }).Once()
320+ h.vid.EXPECT().Close().Return(nil).Once()
321+ require.ErrorIs(t, h.svc.Run(ctx, sel), boom)
322+ })
323+ }
324+ t.Run("wrapped cancellation alone is a clean stop", func(t *testing.T) {
325+ h := newHarness(t)
326+ sel := rec(0)
327+ ctx, cancel := context.WithCancel(context.Background())
328+ start := recordOrder(h, sel)
329+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
330+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
331+ cancel()
332+ return ports.Frame{}, fmt.Errorf("wayland: %w", errors.Join(context.Canceled, context.Canceled))
333+ }).Once()
334+ h.vid.EXPECT().Close().Return(nil).Once()
335+ require.NoError(t, h.svc.Run(ctx, sel))
336+ })
337+}
338+
339+// slotRecorder tracks the most recently captured frame: only it may be written.
340+type slotRecorder struct {
341+ current *byte
342+ captures atomic.Int32
343+ writes atomic.Int32
344+}
345+
346+func newFrame() ports.Frame {
347+ f := frame
348+ f.Pixels = make([]byte, len(frame.Pixels))
349+ return f
350+}
351+
352+func TestRecordExactFrameCountWithSlowStartAndCapture(t *testing.T) {
353+ cases := []struct {
354+ name string
355+ duration time.Duration
356+ fps int
357+ want int32
358+ }{
359+ {"slow capture 60fps", 300 * time.Millisecond, 60, 18},
360+ {"fractional slot rounds up", 25 * time.Millisecond, 60, 2},
361+ {"single slot", 10 * time.Millisecond, 30, 1},
362+ {"fast", 100 * time.Millisecond, 30, 3},
363+ }
364+ for _, tc := range cases {
365+ t.Run(tc.name, func(t *testing.T) {
366+ h := newHarness(t)
367+ sel := rec(tc.duration)
368+ sel.Video.FPS = tc.fps
369+ var r slotRecorder
370+ first := newFrame()
371+ r.current = &first.Pixels[0]
372+ var closed atomic.Bool
373+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
374+ r.captures.Add(1)
375+ f := first
376+ if r.captures.Load() > 1 {
377+ time.Sleep(25 * time.Millisecond)
378+ f = newFrame()
379+ }
380+ r.current = &f.Pixels[0]
381+ return f, nil
382+ }).Maybe()
383+ s := h.vid.EXPECT().Start(mock.Anything, mock.Anything, sel.Path, sel.Video).RunAndReturn(func(context.Context, ports.Frame, string, ports.VideoSettings) error {
384+ time.Sleep(100 * time.Millisecond) // must not count against the duration
385+ return nil
386+ }).Once()
387+ h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.Frame) error {
388+ require.False(t, closed.Load(), "write after close")
389+ require.Same(t, r.current, &f.Pixels[0], "stale or unknown frame written")
390+ r.writes.Add(1)
391+ return nil
392+ }).NotBefore(s).Maybe()
393+ h.vid.EXPECT().Close().RunAndReturn(func() error {
394+ closed.Store(true)
395+ return nil
396+ }).Once()
397+
398+ began := time.Now()
399+ require.NoError(t, h.svc.Run(context.Background(), sel))
400+ assert.Equal(t, tc.want, r.writes.Load())
401+ assert.LessOrEqual(t, r.captures.Load(), tc.want)
402+ assert.GreaterOrEqual(t, time.Since(began), 100*time.Millisecond+tc.duration-time.Second/time.Duration(tc.fps))
403+ })
404+ }
405+}
406+
407+// backpressure wires a slow Write and distinct capture storage, and reports
408+// fresh captures, total writes and the longest run of repeated frames.
409+type backpressure struct {
410+ captures atomic.Int32
411+ writes atomic.Int32
412+ maxRun int
413+ run int
414+ last *byte
415+}
416+
417+func (bp *backpressure) wire(h harness, start *mock.Call, slow time.Duration) {
418+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
419+ bp.captures.Add(1)
420+ return newFrame(), nil
421+ }).Maybe()
422+ h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, f ports.Frame) error {
423+ time.Sleep(slow)
424+ bp.writes.Add(1)
425+ if &f.Pixels[0] == bp.last {
426+ bp.run++
427+ } else {
428+ bp.run = 0
429+ }
430+ bp.maxRun = max(bp.maxRun, bp.run)
431+ bp.last = &f.Pixels[0]
432+ return nil
433+ }).NotBefore(start).Maybe()
434+}
435+
436+func TestRecordEncoderBackpressureKeepsCapturesFresh(t *testing.T) {
437+ h := newHarness(t)
438+ sel := rec(2 * time.Second)
439+ sel.Video.FPS = 60
440+ start := recordOrder(h, sel)
441+ var bp backpressure
442+ bp.wire(h, start, 20*time.Millisecond)
443+ h.vid.EXPECT().Close().Return(nil).Once()
444+
445+ require.NoError(t, h.svc.Run(context.Background(), sel))
446+ assert.Equal(t, int32(120), bp.writes.Load(), "exact encoded length")
447+ assert.GreaterOrEqual(t, bp.captures.Load(), int32(40), "captures must not starve")
448+ assert.LessOrEqual(t, bp.maxRun, 2, "repeat backlog is bounded")
449+}
450+
451+func TestRecordSlightWriteOverrunStaysFresh(t *testing.T) {
452+ h := newHarness(t)
453+ sel := rec(time.Second)
454+ sel.Video.FPS = 60
455+ start := recordOrder(h, sel)
456+ var bp backpressure
457+ bp.wire(h, start, 17*time.Millisecond) // just over the 16.7ms slot
458+ h.vid.EXPECT().Close().Return(nil).Once()
459+
460+ require.NoError(t, h.svc.Run(context.Background(), sel))
461+ assert.Equal(t, int32(60), bp.writes.Load(), "exact encoded length")
462+ assert.GreaterOrEqual(t, bp.captures.Load(), int32(54), "over 90 percent of frames are fresh")
463+}
464+
465+func TestRecordSlowCaptureStillRepeats(t *testing.T) {
466+ h := newHarness(t)
467+ sel := rec(time.Second)
468+ sel.Video.FPS = 60
469+ start := recordOrder(h, sel)
470+ var captures atomic.Int32
471+ h.src.EXPECT().Capture(mock.Anything, target).RunAndReturn(func(context.Context, ports.Target) (ports.Frame, error) {
472+ captures.Add(1)
473+ time.Sleep(25 * time.Millisecond)
474+ return newFrame(), nil
475+ }).Maybe()
476+ var writes atomic.Int32
477+ h.vid.EXPECT().Write(mock.Anything, mock.Anything).RunAndReturn(func(_ context.Context, _ ports.Frame) error {
478+ time.Sleep(time.Millisecond)
479+ writes.Add(1)
480+ return nil
481+ }).NotBefore(start).Maybe()
482+ h.vid.EXPECT().Close().Return(nil).Once()
483+
484+ require.NoError(t, h.svc.Run(context.Background(), sel))
485+ assert.Equal(t, int32(60), writes.Load(), "exact encoded length")
486+ assert.Less(t, captures.Load(), int32(45), "slow captures are covered by repeated frames")
487+ assert.Greater(t, captures.Load(), int32(10))
488+}
489+
490+func TestRecordUntimedBackpressureBoundsRepeats(t *testing.T) {
491+ h := newHarness(t)
492+ sel := rec(0)
493+ sel.Video.FPS = 60
494+ ctx, cancel := context.WithCancel(context.Background())
495+ start := recordOrder(h, sel)
496+ var bp backpressure
497+ bp.wire(h, start, 40*time.Millisecond) // slower than two slots per frame
498+ h.vid.EXPECT().Close().Return(nil).Once()
499+
500+ time.AfterFunc(time.Second, cancel)
501+ require.NoError(t, h.svc.Run(ctx, sel))
502+ assert.Greater(t, bp.captures.Load(), int32(5))
503+ assert.LessOrEqual(t, bp.maxRun, 3, "repeated writes between fresh captures")
504+}
505+
506+func TestRecordCancelWritesNothingAfterStop(t *testing.T) {
507+ h := newHarness(t)
508+ sel := rec(0)
509+ sel.Video.FPS = 50
510+ ctx, cancel := context.WithCancel(context.Background())
511+ start := recordOrder(h, sel)
512+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
513+ var writes atomic.Int32
514+ var closed atomic.Bool
515+ h.vid.EXPECT().Write(mock.Anything, frame).RunAndReturn(func(ctx context.Context, _ ports.Frame) error {
516+ require.NoError(t, ctx.Err(), "write after stop")
517+ if writes.Add(1) == 4 {
518+ cancel() // stop arrives while this frame is being written
519+ }
520+ return nil
521+ }).NotBefore(start).Maybe()
522+ h.vid.EXPECT().Close().RunAndReturn(func() error {
523+ closed.Store(true)
524+ return nil
525+ }).Once()
526+
527+ require.NoError(t, h.svc.Run(ctx, sel))
528+ assert.Equal(t, int32(4), writes.Load(), "recording ends at the last frame written before the stop")
529+ assert.True(t, closed.Load())
530+}
531+
532+func TestRecordHugeDurationDoesNotOverflow(t *testing.T) {
533+ h := newHarness(t)
534+ sel := rec(time.Duration(1<<63 - 1))
535+ ctx, cancel := context.WithCancel(context.Background())
536+ start := recordOrder(h, sel)
537+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
538+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
539+ h.vid.EXPECT().Close().Return(nil).Once()
540+ time.AfterFunc(40*time.Millisecond, cancel)
541+ require.NoError(t, h.svc.Run(ctx, sel))
542+}
543+
544+func TestRecordAlreadyCancelled(t *testing.T) {
545+ h := newHarness(t)
546+ ctx, cancel := context.WithCancel(context.Background())
547+ cancel()
548+ require.ErrorIs(t, h.svc.Run(ctx, rec(0)), context.Canceled)
549+}
550+
551+func TestRecordStartFailures(t *testing.T) {
552+ boom := errors.New("boom")
553+ abortErr := errors.New("abort failed")
554+ t.Run("first capture", func(t *testing.T) {
555+ h := newHarness(t)
556+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
557+ require.ErrorIs(t, h.svc.Run(context.Background(), rec(0)), boom)
558+ })
559+ t.Run("invalid first frame", func(t *testing.T) {
560+ h := newHarness(t)
561+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{Width: 2}, nil).Once()
562+ require.Error(t, h.svc.Run(context.Background(), rec(0)))
563+ })
564+ t.Run("start with abort failure", func(t *testing.T) {
565+ h := newHarness(t)
566+ sel := rec(0)
567+ c := h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
568+ s := h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(boom).Once().NotBefore(c)
569+ h.vid.EXPECT().Abort().Return(abortErr).Once().NotBefore(s)
570+ err := h.svc.Run(context.Background(), sel)
571+ require.ErrorIs(t, err, boom)
572+ require.ErrorIs(t, err, abortErr)
573+ })
574+ t.Run("path exists", func(t *testing.T) {
575+ h := newHarness(t)
576+ sel := rec(0)
577+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
578+ h.vid.EXPECT().Start(mock.Anything, frame, sel.Path, sel.Video).Return(ports.ErrPathExists).Once()
579+ h.vid.EXPECT().Abort().Return(nil).Once()
580+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrPathExists)
581+ })
582+}
583+
584+func TestRecordRuntimeFailures(t *testing.T) {
585+ boom := errors.New("boom")
586+ closeErr := errors.New("close failed")
587+
588+ t.Run("first write", func(t *testing.T) {
589+ h := newHarness(t)
590+ sel := rec(0)
591+ start := recordOrder(h, sel)
592+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(boom).Once().NotBefore(start)
593+ h.vid.EXPECT().Abort().Return(closeErr).Once().NotBefore(w)
594+ err := h.svc.Run(context.Background(), sel)
595+ require.ErrorIs(t, err, boom)
596+ require.ErrorIs(t, err, closeErr)
597+ })
598+ t.Run("later capture", func(t *testing.T) {
599+ h := newHarness(t)
600+ sel := rec(0)
601+ start := recordOrder(h, sel)
602+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
603+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
604+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
605+ err := h.svc.Run(context.Background(), sel)
606+ require.ErrorIs(t, err, boom)
607+ assert.NotErrorIs(t, err, closeErr)
608+ })
609+ t.Run("later invalid frame", func(t *testing.T) {
610+ h := newHarness(t)
611+ sel := rec(0)
612+ start := recordOrder(h, sel)
613+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
614+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, nil).Once()
615+ h.vid.EXPECT().Close().Return(nil).Once().NotBefore(w)
616+ require.Error(t, h.svc.Run(context.Background(), sel))
617+ })
618+ t.Run("later capture and close", func(t *testing.T) {
619+ h := newHarness(t)
620+ sel := rec(0)
621+ start := recordOrder(h, sel)
622+ w := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
623+ h.src.EXPECT().Capture(mock.Anything, target).Return(ports.Frame{}, boom).Once()
624+ h.vid.EXPECT().Close().Return(closeErr).Once().NotBefore(w)
625+ err := h.svc.Run(context.Background(), sel)
626+ require.ErrorIs(t, err, boom)
627+ require.ErrorIs(t, err, closeErr)
628+ })
629+ t.Run("geometry change", func(t *testing.T) {
630+ h := newHarness(t)
631+ sel := rec(0)
632+ start := recordOrder(h, sel)
633+ first := h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).Once().NotBefore(start)
634+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Once()
635+ h.vid.EXPECT().Write(mock.Anything, frame).Return(ports.ErrGeometryChanged).Once().NotBefore(first)
636+ h.vid.EXPECT().Close().Return(nil).Once()
637+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), ports.ErrGeometryChanged)
638+ })
639+ t.Run("close only", func(t *testing.T) {
640+ h := newHarness(t)
641+ sel := rec(20 * time.Millisecond)
642+ start := recordOrder(h, sel)
643+ h.src.EXPECT().Capture(mock.Anything, target).Return(frame, nil).Maybe()
644+ h.vid.EXPECT().Write(mock.Anything, frame).Return(nil).NotBefore(start).Maybe()
645+ h.vid.EXPECT().Close().Return(closeErr).Once()
646+ require.ErrorIs(t, h.svc.Run(context.Background(), sel), closeErr)
647+ })
648+}

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 2

Tags 9

↑↓ moveenter openesc close